diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 44c38afdec6..29d5a95ea01 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -62,7 +62,7 @@
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
- "url": "./script/json_schemas/manifest_schema.json"
+ "url": "${containerWorkspaceFolder}/script/json_schemas/manifest_schema.json"
}
]
}
diff --git a/.gitattributes b/.gitattributes
index eca98fc228f..6a18819be9d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,3 +11,14 @@
*.pcm binary
Dockerfile.dev linguist-language=Dockerfile
+
+# Generated files
+CODEOWNERS linguist-generated=true
+Dockerfile linguist-generated=true
+homeassistant/generated/*.py linguist-generated=true
+mypy.ini linguist-generated=true
+requirements.txt linguist-generated=true
+requirements_all.txt linguist-generated=true
+requirements_test_all.txt linguist-generated=true
+requirements_test_pre_commit.txt linguist-generated=true
+script/hassfest/docker/Dockerfile linguist-generated=true
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index 20b1bd4c718..aa4bfc60c11 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -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.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: translations
path: translations.tar.gz
@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v7
+ uses: dawidd6/action-download-artifact@v8
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v7
+ uses: dawidd6/action-download-artifact@v8
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -454,7 +454,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
- uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
+ uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
- uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
+ uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
- uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0
+ uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 98f4fb04e34..a58648212e3 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -40,9 +40,9 @@ env:
CACHE_VERSION: 11
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
- HA_SHORT_VERSION: "2025.1"
- DEFAULT_PYTHON: "3.12"
- ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
+ HA_SHORT_VERSION: "2025.2"
+ DEFAULT_PYTHON: "3.13"
+ ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -234,7 +234,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -279,7 +279,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -319,7 +319,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -359,7 +359,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -469,7 +469,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -537,7 +537,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -572,7 +572,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -605,7 +605,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -643,7 +643,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -661,7 +661,7 @@ jobs:
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -686,7 +686,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -733,7 +733,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -778,7 +778,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -859,7 +859,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -877,7 +877,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.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -923,7 +923,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -979,14 +979,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.5.0
+ uses: actions/upload-artifact@v4.6.0
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.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1044,7 +1044,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1106,7 +1106,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.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1114,7 +1114,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1173,7 +1173,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1236,7 +1236,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.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1244,7 +1244,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1273,7 +1273,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
- uses: codecov/codecov-action@v5.1.2
+ uses: codecov/codecov-action@v5.3.1
with:
fail_ci_if_error: true
flags: full-suite
@@ -1319,7 +1319,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1378,14 +1378,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.5.0
+ uses: actions/upload-artifact@v4.6.0
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.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1411,7 +1411,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
- uses: codecov/codecov-action@v5.1.2
+ uses: codecov/codecov-action@v5.3.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 511ec963db3..d7f46b176cd 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3.28.0
+ uses: github/codeql-action/init@v3.28.6
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.28.0
+ uses: github/codeql-action/analyze@v3.28.6
with:
category: "/language:python"
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index b51550767b8..11c87266525 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
- uses: actions/stale@v9.0.0
+ uses: actions/stale@v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
- uses: actions/stale@v9.0.0
+ uses: actions/stale@v9.1.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
- uses: actions/stale@v9.0.0
+ uses: actions/stale@v9.1.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
index 3fffc41e60c..619d83aef51 100644
--- a/.github/workflows/translations.yml
+++ b/.github/workflows/translations.yml
@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
- DEFAULT_PYTHON: "3.12"
+ DEFAULT_PYTHON: "3.13"
jobs:
upload:
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 9ea9a557105..41e7b351184 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
- DEFAULT_PYTHON: "3.12"
+ DEFAULT_PYTHON: "3.13"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
@@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.3.0
+ uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -76,18 +76,37 @@ jobs:
# Use C-Extension for SQLAlchemy
echo "REQUIRE_SQLALCHEMY_CEXT=1"
+
+ # Add additional pip wheel build constraints
+ echo "PIP_CONSTRAINT=build_constraints.txt"
) > .env_file
+ - name: Write pip wheel build constraints
+ run: |
+ (
+ # ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
+ # this caused the numpy builds to fail
+ # https://github.com/scikit-build/ninja-python-distributions/issues/274
+ echo "ninja==1.11.1.1"
+ ) > build_constraints.txt
+
- name: Upload env_file
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: env_file
path: ./.env_file
include-hidden-files: true
overwrite: true
+ - name: Upload build_constraints
+ uses: actions/upload-artifact@v4.6.0
+ with:
+ name: build_constraints
+ path: ./build_constraints.txt
+ overwrite: true
+
- name: Upload requirements_diff
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -99,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
- uses: actions/upload-artifact@v4.5.0
+ uses: actions/upload-artifact@v4.6.0
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -112,7 +131,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- abi: ["cp312", "cp313"]
+ abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
@@ -123,6 +142,11 @@ jobs:
with:
name: env_file
+ - name: Download build_constraints
+ uses: actions/download-artifact@v4.1.8
+ with:
+ name: build_constraints
+
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.8
with:
@@ -142,7 +166,7 @@ jobs:
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
- apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev"
+ apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@@ -156,7 +180,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- abi: ["cp312", "cp313"]
+ abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
@@ -167,6 +191,11 @@ jobs:
with:
name: env_file
+ - name: Download build_constraints
+ uses: actions/download-artifact@v4.1.8
+ with:
+ name: build_constraints
+
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.8
with:
@@ -205,7 +234,7 @@ jobs:
arch: ${{ matrix.arch }}
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;zlib-dev"
+ 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;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@@ -219,7 +248,7 @@ jobs:
arch: ${{ matrix.arch }}
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;zlib-dev"
+ 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;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@@ -233,7 +262,7 @@ jobs:
arch: ${{ matrix.arch }}
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;zlib-dev"
+ 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;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a4568552780..805e3ac4dbd 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.8.3
+ rev: v0.9.1
hooks:
- id: ruff
args:
@@ -61,13 +61,14 @@ repos:
name: mypy
entry: script/run-in-env.sh mypy
language: script
- types_or: [python, pyi]
require_serial: true
+ types_or: [python, pyi]
files: ^(homeassistant|pylint)/.+\.(py|pyi)$
- id: pylint
name: pylint
- entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y
+ entry: script/run-in-env.sh pylint --ignore-missing-annotations=y
language: script
+ require_serial: true
types_or: [python, pyi]
files: ^(homeassistant|tests)/.+\.(py|pyi)$
- id: gen_requirements_all
diff --git a/.strict-typing b/.strict-typing
index 07a96a3d692..4cebcb6f445 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -217,6 +217,7 @@ homeassistant.components.goalzero.*
homeassistant.components.google.*
homeassistant.components.google_assistant_sdk.*
homeassistant.components.google_cloud.*
+homeassistant.components.google_drive.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.govee_ble.*
@@ -224,8 +225,10 @@ homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*
+homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
+homeassistant.components.heos.*
homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.history_stats.*
@@ -236,6 +239,7 @@ homeassistant.components.homeassistant_green.*
homeassistant.components.homeassistant_hardware.*
homeassistant.components.homeassistant_sky_connect.*
homeassistant.components.homeassistant_yellow.*
+homeassistant.components.homee.*
homeassistant.components.homekit.*
homeassistant.components.homekit_controller
homeassistant.components.homekit_controller.alarm_control_panel
@@ -261,6 +265,7 @@ homeassistant.components.image_processing.*
homeassistant.components.image_upload.*
homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
+homeassistant.components.incomfort.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -291,6 +296,7 @@ homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
+homeassistant.components.letpot.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
@@ -305,12 +311,15 @@ homeassistant.components.logbook.*
homeassistant.components.logger.*
homeassistant.components.london_underground.*
homeassistant.components.lookin.*
+homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
+homeassistant.components.mcp.*
+homeassistant.components.mcp_server.*
homeassistant.components.mealie.*
homeassistant.components.media_extractor.*
homeassistant.components.media_player.*
@@ -352,6 +361,7 @@ homeassistant.components.number.*
homeassistant.components.nut.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.*
+homeassistant.components.onedrive.*
homeassistant.components.onewire.*
homeassistant.components.onkyo.*
homeassistant.components.open_meteo.*
@@ -362,11 +372,14 @@ homeassistant.components.openuv.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.overkiz.*
+homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
+homeassistant.components.pandora.*
homeassistant.components.panel_custom.*
homeassistant.components.peblar.*
homeassistant.components.peco.*
homeassistant.components.persistent_notification.*
+homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
@@ -380,6 +393,8 @@ homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.*
+homeassistant.components.python_script.*
+homeassistant.components.qbus.*
homeassistant.components.qnap_qsw.*
homeassistant.components.rabbitair.*
homeassistant.components.radarr.*
diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json
index ace0a988bf5..8c57059959b 100644
--- a/.vscode/settings.default.json
+++ b/.vscode/settings.default.json
@@ -1,5 +1,5 @@
{
- // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json
+ // Please keep this file (mostly!) in sync with settings in home-assistant/.devcontainer/devcontainer.json
// Added --no-cov to work around TypeError: message must be set
// https://github.com/microsoft/vscode-python/issues/14067
"python.testing.pytestArgs": ["--no-cov"],
@@ -12,6 +12,7 @@
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
+ // This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
diff --git a/CODEOWNERS b/CODEOWNERS
index 8ab0994cdac..7baeea72178 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -566,6 +566,8 @@ build.json @home-assistant/supervisor
/tests/components/google_assistant_sdk/ @tronikos
/homeassistant/components/google_cloud/ @lufton @tronikos
/tests/components/google_cloud/ @lufton @tronikos
+/homeassistant/components/google_drive/ @tronikos
+/tests/components/google_drive/ @tronikos
/homeassistant/components/google_generative_ai_conversation/ @tronikos
/tests/components/google_generative_ai_conversation/ @tronikos
/homeassistant/components/google_mail/ @tkdrob
@@ -637,6 +639,8 @@ build.json @home-assistant/supervisor
/tests/components/homeassistant_sky_connect/ @home-assistant/core
/homeassistant/components/homeassistant_yellow/ @home-assistant/core
/tests/components/homeassistant_yellow/ @home-assistant/core
+/homeassistant/components/homee/ @Taraman17
+/tests/components/homee/ @Taraman17
/homeassistant/components/homekit/ @bdraco
/tests/components/homekit/ @bdraco
/homeassistant/components/homekit_controller/ @Jc2k @bdraco
@@ -680,12 +684,12 @@ build.json @home-assistant/supervisor
/homeassistant/components/iammeter/ @lewei50
/homeassistant/components/iaqualink/ @flz
/tests/components/iaqualink/ @flz
-/homeassistant/components/ibeacon/ @bdraco
-/tests/components/ibeacon/ @bdraco
/homeassistant/components/icloud/ @Quentame @nzapponi
/tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/idasen_desk/ @abmantis
/tests/components/idasen_desk/ @abmantis
+/homeassistant/components/igloohome/ @keithle888
+/tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte
/tests/components/ign_sismologia/ @exxamalte
/homeassistant/components/image/ @home-assistant/core
@@ -827,6 +831,8 @@ build.json @home-assistant/supervisor
/tests/components/led_ble/ @bdraco
/homeassistant/components/lektrico/ @lektrico
/tests/components/lektrico/ @lektrico
+/homeassistant/components/letpot/ @jpelgrom
+/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
@@ -887,6 +893,10 @@ build.json @home-assistant/supervisor
/tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter
/tests/components/matter/ @home-assistant/matter
+/homeassistant/components/mcp/ @allenporter
+/tests/components/mcp/ @allenporter
+/homeassistant/components/mcp_server/ @allenporter
+/tests/components/mcp_server/ @allenporter
/homeassistant/components/mealie/ @joostlek @andrew-codechimp
/tests/components/mealie/ @joostlek @andrew-codechimp
/homeassistant/components/meater/ @Sotolotl @emontnemery
@@ -1016,7 +1026,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum
/homeassistant/components/nissan_leaf/ @filcole
-/homeassistant/components/nmbs/ @thibmaek
/homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
/tests/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1064,12 +1073,14 @@ build.json @home-assistant/supervisor
/tests/components/oncue/ @bdraco @peterager
/homeassistant/components/ondilo_ico/ @JeromeHXP
/tests/components/ondilo_ico/ @JeromeHXP
+/homeassistant/components/onedrive/ @zweckj
+/tests/components/onedrive/ @zweckj
/homeassistant/components/onewire/ @garbled1 @epenet
/tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
/tests/components/onkyo/ @arturpragacz @eclair4151
-/homeassistant/components/onvif/ @hunterjm
-/tests/components/onvif/ @hunterjm
+/homeassistant/components/onvif/ @hunterjm @jterrace
+/tests/components/onvif/ @hunterjm @jterrace
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/openai_conversation/ @balloob
@@ -1103,8 +1114,10 @@ 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 @alexfp14
-/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
+/homeassistant/components/overkiz/ @imicknl
+/tests/components/overkiz/ @imicknl
+/homeassistant/components/overseerr/ @joostlek
+/tests/components/overseerr/ @joostlek
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1135,8 +1148,8 @@ build.json @home-assistant/supervisor
/tests/components/plaato/ @JohNan
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
-/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck
-/tests/components/plugwise/ @CoMPaTech @bouwew @frenck
+/homeassistant/components/plugwise/ @CoMPaTech @bouwew
+/tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike
@@ -1182,6 +1195,8 @@ build.json @home-assistant/supervisor
/tests/components/pyload/ @tr4nt0r
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
/tests/components/qbittorrent/ @geoffreylagaisse @finder39
+/homeassistant/components/qbus/ @Qbus-iot @thomasddn
+/tests/components/qbus/ @Qbus-iot @thomasddn
/homeassistant/components/qingping/ @bdraco
/tests/components/qingping/ @bdraco
/homeassistant/components/qld_bushfire/ @exxamalte
@@ -1258,8 +1273,8 @@ build.json @home-assistant/supervisor
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi
-/homeassistant/components/roborock/ @Lash-L
-/tests/components/roborock/ @Lash-L
+/homeassistant/components/roborock/ @Lash-L @allenporter
+/tests/components/roborock/ @Lash-L @allenporter
/homeassistant/components/roku/ @ctalkington
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
@@ -1278,6 +1293,7 @@ build.json @home-assistant/supervisor
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/homeassistant/components/russound_rio/ @noahhusby
/tests/components/russound_rio/ @noahhusby
+/homeassistant/components/russound_rnet/ @noahhusby
/homeassistant/components/ruuvi_gateway/ @akx
/tests/components/ruuvi_gateway/ @akx
/homeassistant/components/ruuvitag_ble/ @akx
@@ -1371,8 +1387,8 @@ build.json @home-assistant/supervisor
/tests/components/slide_local/ @dontinelli
/homeassistant/components/slimproto/ @marcelveldt
/tests/components/slimproto/ @marcelveldt
-/homeassistant/components/sma/ @kellerza @rklomp
-/tests/components/sma/ @kellerza @rklomp
+/homeassistant/components/sma/ @kellerza @rklomp @erwindouna
+/tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/smappee/ @bsmappee
/tests/components/smappee/ @bsmappee
/homeassistant/components/smart_meter_texas/ @grahamwetzler
@@ -1398,8 +1414,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
-/homeassistant/components/solax/ @squishykid
-/tests/components/solax/ @squishykid
+/homeassistant/components/solax/ @squishykid @Darsstar
+/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept @sebfortier2288
/tests/components/soma/ @ratsept @sebfortier2288
/homeassistant/components/sonarr/ @ctalkington
@@ -1478,8 +1494,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
-/homeassistant/components/tado/ @chiefdragon @erwindouna
-/tests/components/tado/ @chiefdragon @erwindouna
+/homeassistant/components/tado/ @erwindouna
+/tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @balloob @dmulcahey
/tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck
@@ -1573,8 +1589,8 @@ build.json @home-assistant/supervisor
/tests/components/triggercmd/ @rvmey
/homeassistant/components/tts/ @home-assistant/core
/tests/components/tts/ @home-assistant/core
-/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
-/tests/components/tuya/ @Tuya @zlinoliver @frenck
+/homeassistant/components/tuya/ @Tuya @zlinoliver
+/tests/components/tuya/ @Tuya @zlinoliver
/homeassistant/components/twentemilieu/ @frenck
/tests/components/twentemilieu/ @frenck
/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen
@@ -1618,15 +1634,15 @@ build.json @home-assistant/supervisor
/tests/components/valve/ @home-assistant/core
/homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra
-/homeassistant/components/velux/ @Julius2342 @DeerMaximum
-/tests/components/velux/ @Julius2342 @DeerMaximum
+/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
+/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz
/homeassistant/components/version/ @ludeeus
/tests/components/version/ @ludeeus
-/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
-/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
+/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
+/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/vilfo/ @ManneW
diff --git a/Dockerfile b/Dockerfile
index 630fc19496c..171d08731a9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
-RUN pip3 install uv==0.5.8
+RUN pip3 install uv==0.5.21
WORKDIR /usr/src
@@ -55,7 +55,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
- && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
+ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py
index fc47a7d71e9..1c2e8b0dfab 100644
--- a/homeassistant/auth/auth_store.py
+++ b/homeassistant/auth/auth_store.py
@@ -308,7 +308,7 @@ class AuthStore:
credentials.data = data
self._async_schedule_save()
- async def async_load(self) -> None: # noqa: C901
+ async def async_load(self) -> None:
"""Load the users."""
if self._loaded:
raise RuntimeError("Auth storage is already loaded")
diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py
index 8a6430d770a..0edc187e24d 100644
--- a/homeassistant/auth/mfa_modules/__init__.py
+++ b/homeassistant/auth/mfa_modules/__init__.py
@@ -4,9 +4,8 @@ from __future__ import annotations
import logging
import types
-from typing import Any, Generic
+from typing import Any
-from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -35,12 +34,6 @@ DATA_REQS: HassKey[set[str]] = HassKey("mfa_auth_module_reqs_processed")
_LOGGER = logging.getLogger(__name__)
-_MultiFactorAuthModuleT = TypeVar(
- "_MultiFactorAuthModuleT",
- bound="MultiFactorAuthModule",
- default="MultiFactorAuthModule",
-)
-
class MultiFactorAuthModule:
"""Multi-factor Auth Module of validation function."""
@@ -102,7 +95,9 @@ class MultiFactorAuthModule:
raise NotImplementedError
-class SetupFlow(data_entry_flow.FlowHandler, Generic[_MultiFactorAuthModuleT]):
+class SetupFlow[_MultiFactorAuthModuleT: MultiFactorAuthModule = MultiFactorAuthModule](
+ data_entry_flow.FlowHandler
+):
"""Handler for the setup flow."""
def __init__(
diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py
index 6f45dab2b36..7dcccbb1a1e 100644
--- a/homeassistant/auth/models.py
+++ b/homeassistant/auth/models.py
@@ -11,7 +11,7 @@ import uuid
import attr
from attr import Attribute
from attr.setters import validate
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.const import __version__
from homeassistant.data_entry_flow import FlowContext, FlowResult
diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py
index 9c2c7e500ca..6498483a19a 100644
--- a/homeassistant/auth/permissions/__init__.py
+++ b/homeassistant/auth/permissions/__init__.py
@@ -17,12 +17,12 @@ POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA})
__all__ = [
"POLICY_SCHEMA",
- "merge_policies",
- "PermissionLookup",
- "PolicyType",
"AbstractPermissions",
- "PolicyPermissions",
"OwnerPermissions",
+ "PermissionLookup",
+ "PolicyPermissions",
+ "PolicyType",
+ "merge_policies",
]
diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py
index 02f99e7bd71..1155e77b407 100644
--- a/homeassistant/auth/providers/__init__.py
+++ b/homeassistant/auth/providers/__init__.py
@@ -5,9 +5,8 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
import types
-from typing import Any, Generic
+from typing import Any
-from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -47,8 +46,6 @@ AUTH_PROVIDER_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-_AuthProviderT = TypeVar("_AuthProviderT", bound="AuthProvider", default="AuthProvider")
-
class AuthProvider:
"""Provider of user authentication."""
@@ -195,9 +192,8 @@ async def load_auth_provider_module(
return module
-class LoginFlow(
+class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
- Generic[_AuthProviderT],
):
"""Handler for the login flow."""
diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py
index 799fd4d2e16..83299859de9 100644
--- a/homeassistant/auth/providers/trusted_networks.py
+++ b/homeassistant/auth/providers/trusted_networks.py
@@ -21,7 +21,7 @@ import voluptuous as vol
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.network import is_cloud_connection
from .. import InvalidAuthError
diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py
index 57e1c734dfc..4d309469017 100644
--- a/homeassistant/backup_restore.py
+++ b/homeassistant/backup_restore.py
@@ -18,6 +18,7 @@ import securetar
from .const import __version__ as HA_VERSION
RESTORE_BACKUP_FILE = ".HA_RESTORE"
+RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT"
KEEP_BACKUPS = ("backups",)
KEEP_DATABASE = (
"home-assistant_v2.db",
@@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
restore_database=instruction_content["restore_database"],
restore_homeassistant=instruction_content["restore_homeassistant"],
)
- except (FileNotFoundError, KeyError, json.JSONDecodeError):
+ except FileNotFoundError:
+ return None
+ except (KeyError, json.JSONDecodeError) as err:
+ _write_restore_result_file(config_dir, False, err)
return None
finally:
# Always remove the backup instruction file to prevent a boot loop
@@ -119,7 +123,7 @@ def _extract_backup(
Path(
tempdir,
"extracted",
- f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
+ f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}",
),
gzip=backup_meta["compressed"],
key=password_to_key(restore_content.password)
@@ -142,6 +146,7 @@ def _extract_backup(
config_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns(*(keep)),
+ ignore_dangling_symlinks=True,
)
elif restore_content.restore_database:
for entry in KEEP_DATABASE:
@@ -159,6 +164,23 @@ def _extract_backup(
)
+def _write_restore_result_file(
+ config_dir: Path, success: bool, error: Exception | None
+) -> None:
+ """Write the restore result file."""
+ result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE)
+ result_path.write_text(
+ json.dumps(
+ {
+ "success": success,
+ "error": str(error) if error else None,
+ "error_type": str(type(error).__name__) if error else None,
+ }
+ ),
+ encoding="utf-8",
+ )
+
+
def restore_backup(config_dir_path: str) -> bool:
"""Restore the backup file if any.
@@ -177,7 +199,14 @@ def restore_backup(config_dir_path: str) -> bool:
restore_content=restore_content,
)
except FileNotFoundError as err:
- raise ValueError(f"Backup file {backup_file_path} does not exist") from err
+ file_not_found = ValueError(f"Backup file {backup_file_path} does not exist")
+ _write_restore_result_file(config_dir, False, file_not_found)
+ raise file_not_found from err
+ except Exception as err:
+ _write_restore_result_file(config_dir, False, err)
+ raise
+ else:
+ _write_restore_result_file(config_dir, True, None)
if restore_content.remove_after_restore:
backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting")
diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py
index 767716dbe27..d224b0b151d 100644
--- a/homeassistant/block_async_io.py
+++ b/homeassistant/block_async_io.py
@@ -31,7 +31,7 @@ def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool:
def _check_file_allowed(mapped_args: dict[str, Any]) -> bool:
# If the file is in /proc we can ignore it.
args = mapped_args["args"]
- path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721
+ path = args[0] if type(args[0]) is str else str(args[0])
return path.startswith(ALLOWED_FILE_PREFIXES)
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index f1f1835863b..490ce5559a9 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -112,6 +112,11 @@ with contextlib.suppress(ImportError):
# Ensure anyio backend is imported to avoid it being imported in the event loop
from anyio._backends import _asyncio # noqa: F401
+with contextlib.suppress(ImportError):
+ # httpx will import trio if it is installed which does
+ # blocking I/O in the event loop. We want to avoid that.
+ import trio # noqa: F401
+
if TYPE_CHECKING:
from .runner import RuntimeConfig
@@ -156,6 +161,16 @@ FRONTEND_INTEGRATIONS = {
# integrations can be removed and database migration status is
# visible in frontend
"frontend",
+ # Hassio is an after dependency of backup, after dependencies
+ # are not promoted from stage 2 to earlier stages, so we need to
+ # add it here. Hassio needs to be setup before backup, otherwise
+ # the backup integration will think we are a container/core install
+ # when using HAOS or Supervised install.
+ "hassio",
+ # Backup is an after dependency of frontend, after dependencies
+ # are not promoted from stage 2 to earlier stages, so we need to
+ # add it here.
+ "backup",
}
RECORDER_INTEGRATIONS = {
# Setup after frontend
diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json
index 028fa544a5f..872cfc0aac5 100644
--- a/homeassistant/brands/google.json
+++ b/homeassistant/brands/google.json
@@ -5,6 +5,7 @@
"google_assistant",
"google_assistant_sdk",
"google_cloud",
+ "google_drive",
"google_generative_ai_conversation",
"google_mail",
"google_maps",
diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json
index 9da24e76f19..0e00c4a7bc3 100644
--- a/homeassistant/brands/microsoft.json
+++ b/homeassistant/brands/microsoft.json
@@ -2,6 +2,7 @@
"domain": "microsoft",
"name": "Microsoft",
"integrations": [
+ "azure_data_explorer",
"azure_devops",
"azure_event_hub",
"azure_service_bus",
@@ -10,6 +11,7 @@
"microsoft_face",
"microsoft",
"msteams",
+ "onedrive",
"xbox"
]
}
diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json
index b3d57042754..c6887d78042 100644
--- a/homeassistant/components/abode/strings.json
+++ b/homeassistant/components/abode/strings.json
@@ -34,17 +34,17 @@
"services": {
"capture_image": {
"name": "Capture image",
- "description": "Request a new image capture from a camera device.",
+ "description": "Requests a new image capture from a camera device.",
"fields": {
"entity_id": {
"name": "Entity",
- "description": "Entity id of the camera to request an image."
+ "description": "Entity ID of the camera to request an image from."
}
}
},
"change_setting": {
"name": "Change setting",
- "description": "Change an Abode system setting.",
+ "description": "Changes an Abode system setting.",
"fields": {
"setting": {
"name": "Setting",
@@ -58,11 +58,11 @@
},
"trigger_automation": {
"name": "Trigger automation",
- "description": "Trigger an Abode automation.",
+ "description": "Triggers an Abode automation.",
"fields": {
"entity_id": {
"name": "Entity",
- "description": "Entity id of the automation to trigger."
+ "description": "Entity ID of the automation to trigger."
}
}
}
diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json
index 681f3f08555..f39511ad41a 100644
--- a/homeassistant/components/acaia/manifest.json
+++ b/homeassistant/components/acaia/manifest.json
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
- "requirements": ["aioacaia==0.1.13"]
+ "requirements": ["aioacaia==0.1.14"]
}
diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py
index 71f7de89528..3e65374f391 100644
--- a/homeassistant/components/accuweather/config_flow.py
+++ b/homeassistant/components/accuweather/config_flow.py
@@ -12,8 +12,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py
index c1463cd9a08..846164202d8 100644
--- a/homeassistant/components/acer_projector/switch.py
+++ b/homeassistant/components/acer_projector/switch.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py
index 62a62795a05..ec7abe258cf 100644
--- a/homeassistant/components/acmeda/__init__.py
+++ b/homeassistant/components/acmeda/__init__.py
@@ -3,7 +3,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from .hub import PulseHub
diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py
index a5daf27f445..4f2e4f4f63f 100644
--- a/homeassistant/components/acmeda/hub.py
+++ b/homeassistant/components/acmeda/hub.py
@@ -70,7 +70,7 @@ class PulseHub:
async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None:
"""Evaluate entities when hub reports that update has occurred."""
- LOGGER.debug("Hub {update_type.name} updated")
+ LOGGER.debug("Hub %s updated", update_type.name)
if update_type == aiopulse.UpdateType.rollers:
await update_devices(self.hass, self.config_entry, self.api.rollers)
diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py
index b1b9c81c674..41876ce478f 100644
--- a/homeassistant/components/actiontec/device_tracker.py
+++ b/homeassistant/components/actiontec/device_tracker.py
@@ -3,9 +3,9 @@
from __future__ import annotations
import logging
-import telnetlib # pylint: disable=deprecated-module
from typing import Final
+import telnetlib # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.device_tracker import (
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import LEASES_REGEX
diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py
index 9e531c683da..f8ddeba6767 100644
--- a/homeassistant/components/adguard/__init__.py
+++ b/homeassistant/components/adguard/__init__.py
@@ -34,9 +34,12 @@ from .const import (
SERVICE_REMOVE_URL,
)
-SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url})
+SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): vol.Any(cv.url, cv.path)})
SERVICE_ADD_URL_SCHEMA = vol.Schema(
- {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url}
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_URL): vol.Any(cv.url, cv.path),
+ }
)
SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py
index 892390a91eb..da34bd36e2c 100644
--- a/homeassistant/components/ads/__init__.py
+++ b/homeassistant/components/ads/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType
diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py
index 72a12506dc1..560d090caf0 100644
--- a/homeassistant/components/ads/binary_sensor.py
+++ b/homeassistant/components/ads/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py
index c7b0f4f2f8a..15d5b3a7d09 100644
--- a/homeassistant/components/ads/cover.py
+++ b/homeassistant/components/ads/cover.py
@@ -17,7 +17,7 @@ from homeassistant.components.cover import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py
index 5ea4868bf11..3de223e5fc4 100644
--- a/homeassistant/components/ads/light.py
+++ b/homeassistant/components/ads/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ads/select.py b/homeassistant/components/ads/select.py
index 39f813dec27..e31e089d669 100644
--- a/homeassistant/components/ads/select.py
+++ b/homeassistant/components/ads/select.py
@@ -11,7 +11,7 @@ from homeassistant.components.select import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py
index 09579161a94..0fd1b84ffd1 100644
--- a/homeassistant/components/ads/sensor.py
+++ b/homeassistant/components/ads/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py
index 0412a127c95..2506757e9d2 100644
--- a/homeassistant/components/ads/switch.py
+++ b/homeassistant/components/ads/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py
index b94215ec9ea..a251e14b3c3 100644
--- a/homeassistant/components/ads/valve.py
+++ b/homeassistant/components/ads/valve.py
@@ -14,7 +14,7 @@ from homeassistant.components.valve import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py
index 2ad8c2217a2..601b10aeb4a 100644
--- a/homeassistant/components/advantage_air/binary_sensor.py
+++ b/homeassistant/components/advantage_air/binary_sensor.py
@@ -66,7 +66,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Motion sensor."""
super().__init__(instance, ac_key, zone_key)
- self._attr_name = f'{self._zone["name"]} motion'
+ self._attr_name = f"{self._zone['name']} motion"
self._attr_unique_id += "-motion"
@property
@@ -84,7 +84,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone MyZone sensor."""
super().__init__(instance, ac_key, zone_key)
- self._attr_name = f'{self._zone["name"]} myZone'
+ self._attr_name = f"{self._zone['name']} myZone"
self._attr_unique_id += "-myzone"
@property
diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py
index bd3fa970fb9..ab1a1c4f9a0 100644
--- a/homeassistant/components/advantage_air/sensor.py
+++ b/homeassistant/components/advantage_air/sensor.py
@@ -103,7 +103,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Vent Sensor."""
super().__init__(instance, ac_key, zone_key=zone_key)
- self._attr_name = f'{self._zone["name"]} vent'
+ self._attr_name = f"{self._zone['name']} vent"
self._attr_unique_id += "-vent"
@property
@@ -131,7 +131,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone wireless signal sensor."""
super().__init__(instance, ac_key, zone_key)
- self._attr_name = f'{self._zone["name"]} signal'
+ self._attr_name = f"{self._zone['name']} signal"
self._attr_unique_id += "-signal"
@property
@@ -165,7 +165,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Temp Sensor."""
super().__init__(instance, ac_key, zone_key)
- self._attr_name = f'{self._zone["name"]} temperature'
+ self._attr_name = f"{self._zone['name']} temperature"
self._attr_unique_id += "-temp"
@property
diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py
index 385570e145f..c5d7b00a942 100644
--- a/homeassistant/components/aftership/const.py
+++ b/homeassistant/components/aftership/const.py
@@ -7,7 +7,7 @@ from typing import Final
import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
DOMAIN: Final = "aftership"
diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py
index c019634197d..085be2499d4 100644
--- a/homeassistant/components/aftership/sensor.py
+++ b/homeassistant/components/aftership/sensor.py
@@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py
index 32a9b5adedf..ea7b12062e8 100644
--- a/homeassistant/components/airgradient/button.py
+++ b/homeassistant/components/airgradient/button.py
@@ -18,7 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientCoordinator
-from .entity import AirGradientEntity
+from .entity import AirGradientEntity, exception_handler
+
+PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -100,6 +102,7 @@ class AirGradientButton(AirGradientEntity, ButtonEntity):
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
+ @exception_handler
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.coordinator.client)
diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py
index 70fa8a1755b..fa3e77beeca 100644
--- a/homeassistant/components/airgradient/config_flow.py
+++ b/homeassistant/components/airgradient/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for Airgradient."""
+from collections.abc import Mapping
from typing import Any
from airgradient import (
@@ -11,10 +12,15 @@ from airgradient import (
from awesomeversion import AwesomeVersion
import voluptuous as vol
-from homeassistant.components import zeroconf
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ SOURCE_USER,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -37,7 +43,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
await self.client.set_configuration_control(ConfigurationControl.LOCAL)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.data[CONF_HOST] = host = discovery_info.host
@@ -95,10 +101,18 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(
current_measures.serial_number, raise_on_progress=False
)
- self._abort_if_unique_id_configured()
+ if self.source == SOURCE_USER:
+ self._abort_if_unique_id_configured()
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch()
await self.set_configuration_source()
- return self.async_create_entry(
- title=current_measures.model,
+ if self.source == SOURCE_USER:
+ return self.async_create_entry(
+ title=current_measures.model,
+ data={CONF_HOST: user_input[CONF_HOST]},
+ )
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
@@ -106,3 +120,9 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
+
+ async def async_step_reconfigure(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration."""
+ return await self.async_step_user()
diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py
index 03d58645853..d2fc2a9de1b 100644
--- a/homeassistant/components/airgradient/coordinator.py
+++ b/homeassistant/components/airgradient/coordinator.py
@@ -55,7 +55,11 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
measures = await self.client.get_current_measures()
config = await self.client.get_config()
except AirGradientError as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
if measures.firmware_version != self._current_version:
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get_device(
diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py
index 588a799610b..51256051259 100644
--- a/homeassistant/components/airgradient/entity.py
+++ b/homeassistant/components/airgradient/entity.py
@@ -1,7 +1,11 @@
"""Base class for AirGradient entities."""
-from airgradient import get_model_name
+from collections.abc import Callable, Coroutine
+from typing import Any, Concatenate
+from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
+
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -26,3 +30,31 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
serial_number=coordinator.serial_number,
sw_version=measures.firmware_version,
)
+
+
+def exception_handler[_EntityT: AirGradientEntity, **_P](
+ func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
+) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
+ """Decorate AirGradient calls to handle exceptions.
+
+ A decorator that wraps the passed in function, catches AirGradient errors.
+ """
+
+ async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
+ try:
+ await func(self, *args, **kwargs)
+ except AirGradientConnectionError as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
+
+ except AirGradientError as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unknown_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
+
+ return handler
diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py
index 7fd282ddd8b..4265215fa25 100644
--- a/homeassistant/components/airgradient/number.py
+++ b/homeassistant/components/airgradient/number.py
@@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientCoordinator
-from .entity import AirGradientEntity
+from .entity import AirGradientEntity, exception_handler
+
+PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -121,6 +123,7 @@ class AirGradientNumber(AirGradientEntity, NumberEntity):
"""Return the state of the number."""
return self.entity_description.value_fn(self.coordinator.data.config)
+ @exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the selected value."""
await self.entity_description.set_value_fn(self.coordinator.client, int(value))
diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml
index 43816401cdb..7a7f8d5ee1d 100644
--- a/homeassistant/components/airgradient/quality_scale.yaml
+++ b/homeassistant/components/airgradient/quality_scale.yaml
@@ -29,7 +29,7 @@ rules:
unique-config-entry: done
# Silver
- action-exceptions: todo
+ action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
@@ -38,7 +38,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates: todo
+ parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
@@ -68,9 +68,9 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
- exception-translations: todo
+ exception-translations: done
icon-translations: done
- reconfiguration-flow: todo
+ reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py
index af56802d842..8c15102ad3a 100644
--- a/homeassistant/components/airgradient/select.py
+++ b/homeassistant/components/airgradient/select.py
@@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE
from .coordinator import AirGradientCoordinator
-from .entity import AirGradientEntity
+from .entity import AirGradientEntity, exception_handler
+
+PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -216,6 +218,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
"""Return the state of the select."""
return self.entity_description.value_fn(self.coordinator.data.config)
+ @exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.set_value_fn(self.coordinator.client, option)
diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py
index 497d4cc0488..3b20b31f923 100644
--- a/homeassistant/components/airgradient/sensor.py
+++ b/homeassistant/components/airgradient/sensor.py
@@ -35,6 +35,8 @@ from .const import PM_STANDARD, PM_STANDARD_REVERSE
from .coordinator import AirGradientCoordinator
from .entity import AirGradientEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class AirGradientMeasurementSensorEntityDescription(SensorEntityDescription):
@@ -137,6 +139,15 @@ MEASUREMENT_SENSOR_TYPES: tuple[AirGradientMeasurementSensorEntityDescription, .
entity_registry_enabled_default=False,
value_fn=lambda status: status.raw_total_volatile_organic_component,
),
+ AirGradientMeasurementSensorEntityDescription(
+ key="pm02_raw",
+ translation_key="raw_pm02",
+ device_class=SensorDeviceClass.PM25,
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ value_fn=lambda status: status.raw_pm02,
+ ),
)
CONFIG_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = (
diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json
index 6bf7242f2f1..4cf3a6a34ea 100644
--- a/homeassistant/components/airgradient/strings.json
+++ b/homeassistant/components/airgradient/strings.json
@@ -17,7 +17,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1."
+ "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1.",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -119,6 +121,9 @@
"raw_nitrogen": {
"name": "Raw NOx"
},
+ "raw_pm02": {
+ "name": "Raw PM2.5"
+ },
"display_pm_standard": {
"name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]",
"state": {
@@ -162,5 +167,16 @@
"name": "Post data to Airgradient"
}
}
+ },
+ "exceptions": {
+ "communication_error": {
+ "message": "An error occurred while communicating with the Airgradient device: {error}"
+ },
+ "unknown_error": {
+ "message": "An unknown error occurred while communicating with the Airgradient device: {error}"
+ },
+ "update_error": {
+ "message": "An error occurred while communicating with the Airgradient device: {error}"
+ }
}
}
diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py
index 329f704e755..55835fa30a6 100644
--- a/homeassistant/components/airgradient/switch.py
+++ b/homeassistant/components/airgradient/switch.py
@@ -20,7 +20,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientCoordinator
-from .entity import AirGradientEntity
+from .entity import AirGradientEntity, exception_handler
+
+PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -99,11 +101,13 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity):
"""Return the state of the switch."""
return self.entity_description.value_fn(self.coordinator.data.config)
+ @exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_value_fn(self.coordinator.client, True)
await self.coordinator.async_request_refresh()
+ @exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_value_fn(self.coordinator.client, False)
diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py
index 47e71cb4e65..12cec65f791 100644
--- a/homeassistant/components/airgradient/update.py
+++ b/homeassistant/components/airgradient/update.py
@@ -2,7 +2,7 @@
from datetime import timedelta
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.core import HomeAssistant
@@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry, AirGradientCoordinator
from .entity import AirGradientEntity
+PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1)
diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py
index 2811156ac90..de60ef84efa 100644
--- a/homeassistant/components/airly/config_flow.py
+++ b/homeassistant/components/airly/config_flow.py
@@ -13,8 +13,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py
index d0ab16e9758..7cd113125a8 100644
--- a/homeassistant/components/airnow/config_flow.py
+++ b/homeassistant/components/airnow/config_flow.py
@@ -18,8 +18,8 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py
index 32185080d25..9434d368dbe 100644
--- a/homeassistant/components/airnow/coordinator.py
+++ b/homeassistant/components/airnow/coordinator.py
@@ -21,7 +21,6 @@ from .const import (
ATTR_API_CAT_DESCRIPTION,
ATTR_API_CAT_LEVEL,
ATTR_API_CATEGORY,
- ATTR_API_PM25,
ATTR_API_POLLUTANT,
ATTR_API_REPORT_DATE,
ATTR_API_REPORT_HOUR,
@@ -91,18 +90,16 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION]
max_aqi_poll = pollutant
- # Copy other data from PM2.5 Value
- if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25:
- # Copy Report Details
- data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
- data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
- data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
+ # Copy Report Details
+ data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
+ data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
+ data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
- # Copy Station Details
- data[ATTR_API_STATE] = obv[ATTR_API_STATE]
- data[ATTR_API_STATION] = obv[ATTR_API_STATION]
- data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
- data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
+ # Copy Station Details
+ data[ATTR_API_STATE] = obv[ATTR_API_STATE]
+ data[ATTR_API_STATION] = obv[ATTR_API_STATION]
+ data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
+ data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
# Store Overall AQI
data[ATTR_API_AQI] = max_aqi
diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py
index 74d712ccfc6..1b604d72032 100644
--- a/homeassistant/components/airthings/sensor.py
+++ b/homeassistant/components/airthings/sensor.py
@@ -39,45 +39,54 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="temp",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
+ state_class=SensorStateClass.MEASUREMENT,
),
"battery": SensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
+ state_class=SensorStateClass.MEASUREMENT,
),
"light": SensorEntityDescription(
key="light",
native_unit_of_measurement=PERCENTAGE,
translation_key="light",
+ state_class=SensorStateClass.MEASUREMENT,
),
"virusRisk": SensorEntityDescription(
key="virusRisk",
translation_key="virus_risk",
+ state_class=SensorStateClass.MEASUREMENT,
),
"mold": SensorEntityDescription(
key="mold",
translation_key="mold",
+ state_class=SensorStateClass.MEASUREMENT,
),
"rssi": SensorEntityDescription(
key="rssi",
@@ -85,16 +94,19 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
),
"pm1": SensorEntityDescription(
key="pm1",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM1,
+ state_class=SensorStateClass.MEASUREMENT,
),
"pm25": SensorEntityDescription(
key="pm25",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
+ state_class=SensorStateClass.MEASUREMENT,
),
}
@@ -143,8 +155,7 @@ class AirthingsHeaterEnergySensor(
self._id = airthings_device.device_id
self._attr_device_info = DeviceInfo(
configuration_url=(
- "https://dashboard.airthings.com/devices/"
- f"{airthings_device.device_id}"
+ f"https://dashboard.airthings.com/devices/{airthings_device.device_id}"
),
identifiers={(DOMAIN, airthings_device.device_id)},
name=airthings_device.name,
diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py
index 48c7219cbaf..3e7b659bff1 100644
--- a/homeassistant/components/airthings_ble/config_flow.py
+++ b/homeassistant/components/airthings_ble/config_flow.py
@@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={})
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py
index 0dfd82a38c4..248561706a3 100644
--- a/homeassistant/components/airthings_ble/sensor.py
+++ b/homeassistant/components/airthings_ble/sensor.py
@@ -67,18 +67,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
),
"battery": SensorEntityDescription(
key="battery",
@@ -86,24 +89,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
+ suggested_display_precision=0,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
),
"illuminance": SensorEntityDescription(
key="illuminance",
translation_key="illuminance",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
),
}
diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py
index 66726832843..58ad730bc31 100644
--- a/homeassistant/components/airvisual_pro/sensor.py
+++ b/homeassistant/components/airvisual_pro/sensor.py
@@ -50,7 +50,7 @@ SENSOR_DESCRIPTIONS = (
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda settings, status, measurements, history: int(
history.get(
- f'Outdoor {"AQI(US)" if settings["is_aqi_usa"] else "AQI(CN)"}', -1
+ f"Outdoor {'AQI(US)' if settings['is_aqi_usa'] else 'AQI(CN)'}", -1
)
),
translation_key="outdoor_air_quality_index",
diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py
index 5d1f9f051a3..aa168dce858 100644
--- a/homeassistant/components/airzone/__init__.py
+++ b/homeassistant/components/airzone/__init__.py
@@ -5,7 +5,14 @@ from __future__ import annotations
import logging
from typing import Any
-from aioairzone.const import AZD_MAC, AZD_WEBSERVER, DEFAULT_SYSTEM_ID
+from aioairzone.const import (
+ AZD_FIRMWARE,
+ AZD_FULL_NAME,
+ AZD_MAC,
+ AZD_MODEL,
+ AZD_WEBSERVER,
+ DEFAULT_SYSTEM_ID,
+)
from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions
from homeassistant.config_entries import ConfigEntry
@@ -17,6 +24,7 @@ from homeassistant.helpers import (
entity_registry as er,
)
+from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -78,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
options = ConnectionOptions(
entry.data[CONF_HOST],
entry.data[CONF_PORT],
- entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID),
+ entry.data[CONF_ID],
)
airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options)
@@ -88,6 +96,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
entry.runtime_data = coordinator
+ device_registry = dr.async_get(hass)
+
+ ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER)
+ if ws_data is not None:
+ mac = ws_data.get(AZD_MAC, "")
+
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={(DOMAIN, f"{entry.entry_id}_ws")},
+ manufacturer=MANUFACTURER,
+ model=ws_data.get(AZD_MODEL),
+ name=ws_data.get(AZD_FULL_NAME),
+ sw_version=ws_data.get(AZD_FIRMWARE),
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -96,3 +120,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool:
+ """Migrate an old entry."""
+ if entry.version == 1 and entry.minor_version < 2:
+ # Add missing CONF_ID
+ system_id = entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID)
+ new_data = entry.data.copy()
+ new_data[CONF_ID] = system_id
+ hass.config_entries.async_update_entry(
+ entry,
+ data=new_data,
+ minor_version=2,
+ )
+
+ _LOGGER.info(
+ "Migration to configuration version %s.%s successful",
+ entry.version,
+ entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py
index 406fd72a6db..c4088e950e9 100644
--- a/homeassistant/components/airzone/config_flow.py
+++ b/homeassistant/components/airzone/config_flow.py
@@ -10,12 +10,12 @@ from aioairzone.exceptions import AirzoneError, InvalidSystem
from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
@@ -44,6 +44,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
_discovered_ip: str | None = None
_discovered_mac: str | None = None
+ MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -53,6 +54,9 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
+ if CONF_ID not in user_input:
+ user_input[CONF_ID] = DEFAULT_SYSTEM_ID
+
self._async_abort_entries_match(user_input)
airzone = AirzoneLocalApi(
@@ -60,7 +64,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
ConnectionOptions(
user_input[CONF_HOST],
user_input[CONF_PORT],
- user_input.get(CONF_ID, DEFAULT_SYSTEM_ID),
+ user_input[CONF_ID],
),
)
@@ -84,6 +88,9 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
)
title = f"Airzone {user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
+ if user_input[CONF_ID] != DEFAULT_SYSTEM_ID:
+ title += f" #{user_input[CONF_ID]}"
+
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
@@ -93,7 +100,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
self._discovered_ip = discovery_info.ip
diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py
index 61f79eabf52..59d58fb62b0 100644
--- a/homeassistant/components/airzone/entity.py
+++ b/homeassistant/components/airzone/entity.py
@@ -68,8 +68,9 @@ class AirzoneSystemEntity(AirzoneEntity):
model=self.get_airzone_value(AZD_MODEL),
name=f"System {self.system_id}",
sw_version=self.get_airzone_value(AZD_FIRMWARE),
- via_device=(DOMAIN, f"{entry.entry_id}_ws"),
)
+ if AZD_WEBSERVER in self.coordinator.data:
+ self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_unique_id = entry.unique_id or entry.entry_id
@property
@@ -102,8 +103,9 @@ class AirzoneHotWaterEntity(AirzoneEntity):
manufacturer=MANUFACTURER,
model="DHW",
name=self.get_airzone_value(AZD_NAME),
- via_device=(DOMAIN, f"{entry.entry_id}_ws"),
)
+ if AZD_WEBSERVER in self.coordinator.data:
+ self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_unique_id = entry.unique_id or entry.entry_id
def get_airzone_value(self, key: str) -> Any:
diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py
index 4c5e201df8f..fde4638e179 100644
--- a/homeassistant/components/alarm_control_panel/__init__.py
+++ b/homeassistant/components/alarm_control_panel/__init__.py
@@ -7,7 +7,7 @@ from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any, Final, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py
index 72b1084d072..6779eada070 100644
--- a/homeassistant/components/alarm_control_panel/device_action.py
+++ b/homeassistant/components/alarm_control_panel/device_action.py
@@ -23,8 +23,7 @@ from homeassistant.const import (
SERVICE_ALARM_TRIGGER,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py
index cf72133ea12..d7092bbe1c4 100644
--- a/homeassistant/components/alarmdecoder/alarm_control_panel.py
+++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py
@@ -12,8 +12,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py
index 12341c158c0..b6ce87941f6 100644
--- a/homeassistant/components/alert/__init__.py
+++ b/homeassistant/components/alert/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py
index c5b4ad15904..e70055c20b1 100644
--- a/homeassistant/components/alexa/capabilities.py
+++ b/homeassistant/components/alexa/capabilities.py
@@ -50,8 +50,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, State
-import homeassistant.util.color as color_util
-import homeassistant.util.dt as dt_util
+from homeassistant.util import color as color_util, dt as dt_util
from .const import (
API_TEMP_UNITS,
diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py
index 8c139d66369..6a0b1830b7e 100644
--- a/homeassistant/components/alexa/entities.py
+++ b/homeassistant/components/alexa/entities.py
@@ -474,25 +474,30 @@ class ClimateCapabilities(AlexaEntity):
# If we support two modes, one being off, we allow turning on too.
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
- self.entity.domain == climate.DOMAIN
- and climate.HVACMode.OFF
- in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
- or self.entity.domain == climate.DOMAIN
- and (
- supported_features
- & (
- climate.ClimateEntityFeature.TURN_ON
- | climate.ClimateEntityFeature.TURN_OFF
+ (
+ self.entity.domain == climate.DOMAIN
+ and climate.HVACMode.OFF
+ in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
+ )
+ or (
+ self.entity.domain == climate.DOMAIN
+ and (
+ supported_features
+ & (
+ climate.ClimateEntityFeature.TURN_ON
+ | climate.ClimateEntityFeature.TURN_OFF
+ )
)
)
- or self.entity.domain == water_heater.DOMAIN
- and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
+ or (
+ self.entity.domain == water_heater.DOMAIN
+ and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
+ )
):
yield AlexaPowerController(self.entity)
- if (
- self.entity.domain == climate.DOMAIN
- or self.entity.domain == water_heater.DOMAIN
+ if self.entity.domain == climate.DOMAIN or (
+ self.entity.domain == water_heater.DOMAIN
and (
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py
index 0d75ee04b7a..a37a95e59d5 100644
--- a/homeassistant/components/alexa/flash_briefings.py
+++ b/homeassistant/components/alexa/flash_briefings.py
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import template
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
API_PASSWORD,
diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py
index 3eb761dacde..20e3ef1d7c7 100644
--- a/homeassistant/components/alexa/state_report.py
+++ b/homeassistant/components/alexa/state_report.py
@@ -24,7 +24,7 @@ from homeassistant.core import (
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.significant_change import create_checker
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import (
@@ -317,9 +317,8 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
- if (
- new_state.domain == event.DOMAIN
- or new_state.state == STATE_ON
+ if new_state.domain == event.DOMAIN or (
+ new_state.state == STATE_ON
and (old_state is None or old_state.state != STATE_ON)
):
await async_send_doorbell_event_message(
diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py
index 506cb41659a..48d3ae6f526 100644
--- a/homeassistant/components/alpha_vantage/sensor.py
+++ b/homeassistant/components/alpha_vantage/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_CURRENCY, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py
index 62852848a9c..985b3b6dd7c 100644
--- a/homeassistant/components/amazon_polly/tts.py
+++ b/homeassistant/components/amazon_polly/tts.py
@@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import (
SUPPORTED_REGIONS,
SUPPORTED_VOICES,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py
index 469ad7e6e06..374c313a144 100644
--- a/homeassistant/components/ambient_station/__init__.py
+++ b/homeassistant/components/ambient_station/__init__.py
@@ -17,9 +17,8 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
-import homeassistant.helpers.entity_registry as er
from .const import (
ATTR_LAST_DATA,
diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py
index 624e0145b86..313d3263932 100644
--- a/homeassistant/components/amcrest/__init__.py
+++ b/homeassistant/components/amcrest/__init__.py
@@ -37,8 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import async_extract_entity_ids
diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py
index 05581df6371..ce2830d5b14 100644
--- a/homeassistant/components/ampio/air_quality.py
+++ b/homeassistant/components/ampio/air_quality.py
@@ -14,8 +14,8 @@ from homeassistant.components.air_quality import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py
index 9bcddcb868f..0df3b8138e2 100644
--- a/homeassistant/components/analytics/__init__.py
+++ b/homeassistant/components/analytics/__init__.py
@@ -7,7 +7,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HassJob, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py
index b63475c80a4..9339e2986e5 100644
--- a/homeassistant/components/analytics/analytics.py
+++ b/homeassistant/components/analytics/analytics.py
@@ -11,6 +11,7 @@ import uuid
import aiohttp
+from homeassistant import config as conf_util
from homeassistant.components import hassio
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
@@ -22,13 +23,12 @@ from homeassistant.components.recorder import (
DOMAIN as RECORDER_DOMAIN,
get_instance as get_recorder_instance,
)
-import homeassistant.config as conf_util
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.storage import Store
from homeassistant.helpers.system_info import async_get_system_info
diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json
index b6f5d494d0f..ce921072e27 100644
--- a/homeassistant/components/androidtv/strings.json
+++ b/homeassistant/components/androidtv/strings.json
@@ -21,7 +21,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "invalid_unique_id": "Impossible to determine a valid unique id for the device"
+ "invalid_unique_id": "Impossible to determine a valid unique ID for the device"
}
},
"options": {
@@ -38,17 +38,17 @@
}
},
"apps": {
- "title": "Configure Android Apps",
- "description": "Configure application id {app_id}",
+ "title": "Configure Android apps",
+ "description": "Configure application ID {app_id}",
"data": {
- "app_name": "Application Name",
+ "app_name": "Application name",
"app_id": "Application ID",
"app_delete": "Check to delete this application"
}
},
"rules": {
"title": "Configure Android state detection rules",
- "description": "Configure detection rule for application id {rule_id}",
+ "description": "Configure detection rule for application ID {rule_id}",
"data": {
"rule_id": "[%key:component::androidtv::options::step::apps::data::app_id%]",
"rule_values": "List of state detection rules (see documentation)",
diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py
index 4df25247881..78f24fc498c 100644
--- a/homeassistant/components/androidtv_remote/config_flow.py
+++ b/homeassistant/components/androidtv_remote/config_flow.py
@@ -14,7 +14,6 @@ from androidtvremote2 import (
)
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
@@ -31,6 +30,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
SelectSelectorMode,
)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
from .helpers import create_api, get_enable_ime
@@ -142,7 +142,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info)
diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py
index 6b27a61e065..97691c8b028 100644
--- a/homeassistant/components/anel_pwrctrl/switch.py
+++ b/homeassistant/components/anel_pwrctrl/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py
index 400ac6d5899..fe9c6513041 100644
--- a/homeassistant/components/anthemav/config_flow.py
+++ b/homeassistant/components/anthemav/config_flow.py
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN
diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py
index 20e555e9592..e45e849adf6 100644
--- a/homeassistant/components/anthropic/conversation.py
+++ b/homeassistant/components/anthropic/conversation.py
@@ -27,7 +27,7 @@ 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 homeassistant.util import ulid as ulid_util
from . import AnthropicConfigEntry
from .const import (
@@ -164,7 +164,7 @@ class AnthropicConversationEntity(
]
if user_input.conversation_id is None:
- conversation_id = ulid.ulid_now()
+ conversation_id = ulid_util.ulid_now()
messages = []
elif user_input.conversation_id in self.history:
@@ -177,8 +177,8 @@ class AnthropicConversationEntity(
# 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()
+ ulid_util.ulid_to_bytes(user_input.conversation_id)
+ conversation_id = ulid_util.ulid_now()
except ValueError:
conversation_id = user_input.conversation_id
diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json
index 7d51c458e4d..b5cbb36c034 100644
--- a/homeassistant/components/anthropic/manifest.json
+++ b/homeassistant/components/anthropic/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["anthropic==0.31.2"]
+ "requirements": ["anthropic==0.44.0"]
}
diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py
index 68d3f58a63a..40f71ec4e4b 100644
--- a/homeassistant/components/apache_kafka/__init__.py
+++ b/homeassistant/components/apache_kafka/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import ssl as ssl_util
diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py
index 00f757a1fd7..b65c9c33265 100644
--- a/homeassistant/components/apcupsd/config_flow.py
+++ b/homeassistant/components/apcupsd/config_flow.py
@@ -10,8 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-from homeassistant.helpers import selector
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, selector
from .const import CONNECTION_TIMEOUT, DOMAIN
from .coordinator import APCUPSdData
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
index ba71fb0def1..d183d46a717 100644
--- a/homeassistant/components/api/__init__.py
+++ b/homeassistant/components/api/__init__.py
@@ -11,6 +11,7 @@ from aiohttp import web
from aiohttp.web_exceptions import HTTPBadRequest
import voluptuous as vol
+from homeassistant import core as ha
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.components.http import (
@@ -36,7 +37,6 @@ from homeassistant.const import (
URL_API_STREAM,
URL_API_TEMPLATE,
)
-import homeassistant.core as ha
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
from homeassistant.exceptions import (
InvalidEntityFormatError,
diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py
index 5cb92ed892a..5c317755d05 100644
--- a/homeassistant/components/apple_tv/config_flow.py
+++ b/homeassistant/components/apple_tv/config_flow.py
@@ -34,6 +34,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
@@ -204,7 +205,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle device found via zeroconf."""
if discovery_info.ip_address.version == 6:
diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py
index c6b71c64b4f..8a2336eea3b 100644
--- a/homeassistant/components/apple_tv/media_player.py
+++ b/homeassistant/components/apple_tv/media_player.py
@@ -40,7 +40,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import AppleTvConfigEntry, AppleTVManager
from .browse_media import build_app_list
diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py
index 50b272cc1fa..68f10df7886 100644
--- a/homeassistant/components/application_credentials/__init__.py
+++ b/homeassistant/components/application_credentials/__init__.py
@@ -26,8 +26,11 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import collection, config_entry_oauth2_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ collection,
+ config_entry_oauth2_flow,
+ config_validation as cv,
+)
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.loader import (
@@ -38,7 +41,7 @@ from homeassistant.loader import (
from homeassistant.util import slugify
from homeassistant.util.hass_dict import HassKey
-__all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"]
+__all__ = ["AuthorizationServer", "ClientCredential", "async_import_client_credential"]
_LOGGER = logging.getLogger(__name__)
@@ -143,8 +146,6 @@ class ApplicationCredentialsStorageCollection(collection.DictStorageCollection):
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Application Credentials."""
- hass.data[DOMAIN] = {}
-
id_manager = collection.IDManager()
storage_collection = ApplicationCredentialsStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py
index eb4e21c127f..a2efcb577d3 100644
--- a/homeassistant/components/apprise/notify.py
+++ b/homeassistant/components/apprise/notify.py
@@ -17,7 +17,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py
index f6c33f75e53..0b4f9af3401 100644
--- a/homeassistant/components/aprilaire/config_flow.py
+++ b/homeassistant/components/aprilaire/config_flow.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py
index 6b132cfcc95..a5126eda95e 100644
--- a/homeassistant/components/aprilaire/coordinator.py
+++ b/homeassistant/components/aprilaire/coordinator.py
@@ -11,7 +11,7 @@ from pyaprilaire.const import MODELS, Attribute, FunctionalDomain
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py
index fc23fc5e436..fc3dbcabfe8 100644
--- a/homeassistant/components/aprs/device_tracker.py
+++ b/homeassistant/components/aprs/device_tracker.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
diff --git a/homeassistant/components/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py
index 5f2f1393aa0..9be0b5f4cf7 100644
--- a/homeassistant/components/apsystems/config_flow.py
+++ b/homeassistant/components/apsystems/config_flow.py
@@ -8,8 +8,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import DEFAULT_PORT, DOMAIN
diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py
index e56cb826840..2535c66c4ac 100644
--- a/homeassistant/components/apsystems/coordinator.py
+++ b/homeassistant/components/apsystems/coordinator.py
@@ -29,6 +29,8 @@ class ApSystemsSensorData:
class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
"""Coordinator used for all sensors."""
+ device_version: str
+
def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None:
"""Initialize my coordinator."""
super().__init__(
@@ -46,6 +48,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
raise UpdateFailed from None
self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower
+ self.device_version = device_info.devVer
async def _async_update_data(self) -> ApSystemsSensorData:
try:
diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py
index 519f4fffb61..7770b451680 100644
--- a/homeassistant/components/apsystems/entity.py
+++ b/homeassistant/components/apsystems/entity.py
@@ -21,7 +21,8 @@ class ApSystemsEntity(Entity):
"""Initialize the APsystems entity."""
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device_id)},
- serial_number=data.device_id,
manufacturer="APsystems",
model="EZ1-M",
+ serial_number=data.device_id,
+ sw_version=data.coordinator.device_version.split(" ")[1],
)
diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py
index 9c2ee9957af..e0cae5df162 100644
--- a/homeassistant/components/aqualogic/sensor.py
+++ b/homeassistant/components/aqualogic/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py
index ed0cc463263..667842a020c 100644
--- a/homeassistant/components/aqualogic/switch.py
+++ b/homeassistant/components/aqualogic/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py
index 343cb6492da..90660028b83 100644
--- a/homeassistant/components/aquostv/media_player.py
+++ b/homeassistant/components/aquostv/media_player.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/aranet/config_flow.py b/homeassistant/components/aranet/config_flow.py
index db89124c54d..876b175126e 100644
--- a/homeassistant/components/aranet/config_flow.py
+++ b/homeassistant/components/aranet/config_flow.py
@@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address][0], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json
index 6cce7554dd1..3131b00cda6 100644
--- a/homeassistant/components/aranet/manifest.json
+++ b/homeassistant/components/aranet/manifest.json
@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["aranet4==2.4.0"]
+ "requirements": ["aranet4==2.5.1"]
}
diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py
index d7fbd0e4b3b..b5187cba1f4 100644
--- a/homeassistant/components/aranet/sensor.py
+++ b/homeassistant/components/aranet/sensor.py
@@ -22,6 +22,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
ATTR_MANUFACTURER,
+ ATTR_MODEL,
ATTR_NAME,
ATTR_SW_VERSION,
CONCENTRATION_PARTS_PER_MILLION,
@@ -142,6 +143,7 @@ def _sensor_device_info_to_hass(
if adv.readings and adv.readings.name:
hass_device_info[ATTR_NAME] = adv.readings.name
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
+ hass_device_info[ATTR_MODEL] = adv.readings.type.model
if adv.manufacturer_data:
hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version)
return hass_device_info
diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py
index 6c037591688..e1886a1db60 100644
--- a/homeassistant/components/arcam_fmj/config_flow.py
+++ b/homeassistant/components/arcam_fmj/config_flow.py
@@ -9,10 +9,10 @@ from arcam.fmj.client import Client, ConnectionFailed
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
@@ -88,12 +88,12 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered device."""
host = str(urlparse(discovery_info.ssdp_location).hostname)
port = DEFAULT_PORT
- uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN])
+ uuid = get_uniqueid_from_udn(discovery_info.upnp[ATTR_UPNP_UDN])
if not uuid:
return self.async_abort(reason="cannot_connect")
diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py
index 00d4d6bbf9b..a99ef049543 100644
--- a/homeassistant/components/arest/binary_sensor.py
+++ b/homeassistant/components/arest/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py
index 8c68c13018b..6554704b230 100644
--- a/homeassistant/components/arest/sensor.py
+++ b/homeassistant/components/arest/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py
index bcdba36cb58..7539336c38b 100644
--- a/homeassistant/components/arest/switch.py
+++ b/homeassistant/components/arest/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME, CONF_RESOURCE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py
index c3650587690..828528508ec 100644
--- a/homeassistant/components/arris_tg2492lg/device_tracker.py
+++ b/homeassistant/components/arris_tg2492lg/device_tracker.py
@@ -13,8 +13,8 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
DEFAULT_HOST = "192.168.178.1"
diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py
index ef622ef9826..c2f0d44a6f8 100644
--- a/homeassistant/components/aruba/device_tracker.py
+++ b/homeassistant/components/aruba/device_tracker.py
@@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -90,7 +90,7 @@ class ArubaDeviceScanner(DeviceScanner):
"""Retrieve data from Aruba Access Point and return parsed result."""
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
- ssh = pexpect.spawn(connect)
+ ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
query = ssh.expect(
[
"password:",
@@ -125,12 +125,12 @@ class ArubaDeviceScanner(DeviceScanner):
ssh.expect("#")
ssh.sendline("show clients")
ssh.expect("#")
- devices_result = ssh.before.split(b"\r\n")
+ devices_result = (ssh.before or "").splitlines()
ssh.sendline("exit")
devices: dict[str, dict[str, str]] = {}
for device in devices_result:
- if match := _DEVICES_REGEX.search(device.decode("utf-8")):
+ if match := _DEVICES_REGEX.search(device):
devices[match.group("ip")] = {
"ip": match.group("ip"),
"mac": match.group("mac").upper(),
diff --git a/homeassistant/components/aruba/manifest.json b/homeassistant/components/aruba/manifest.json
index c98dda754cd..29fba6d9a58 100644
--- a/homeassistant/components/aruba/manifest.json
+++ b/homeassistant/components/aruba/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
"quality_scale": "legacy",
- "requirements": ["pexpect==4.6.0"]
+ "requirements": ["pexpect==4.9.0"]
}
diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py
index ec6d8a646b6..cc7ecc1c426 100644
--- a/homeassistant/components/assist_pipeline/__init__.py
+++ b/homeassistant/components/assist_pipeline/__init__.py
@@ -46,24 +46,24 @@ from .websocket_api import async_register_websocket_api
__all__ = (
"DOMAIN",
- "async_create_default_pipeline",
- "async_get_pipelines",
- "async_migrate_engine",
- "async_setup",
- "async_pipeline_from_audio_stream",
- "async_update_pipeline",
+ "EVENT_RECORDING",
+ "OPTION_PREFERRED",
+ "SAMPLES_PER_CHUNK",
+ "SAMPLE_CHANNELS",
+ "SAMPLE_RATE",
+ "SAMPLE_WIDTH",
"AudioSettings",
"Pipeline",
"PipelineEvent",
"PipelineEventType",
"PipelineNotFound",
"WakeWordSettings",
- "EVENT_RECORDING",
- "OPTION_PREFERRED",
- "SAMPLES_PER_CHUNK",
- "SAMPLE_RATE",
- "SAMPLE_WIDTH",
- "SAMPLE_CHANNELS",
+ "async_create_default_pipeline",
+ "async_get_pipelines",
+ "async_migrate_engine",
+ "async_pipeline_from_audio_stream",
+ "async_setup",
+ "async_update_pipeline",
)
CONFIG_SCHEMA = vol.Schema(
@@ -108,6 +108,7 @@ async def async_pipeline_from_audio_stream(
device_id: str | None = None,
start_stage: PipelineStage = PipelineStage.STT,
end_stage: PipelineStage = PipelineStage.TTS,
+ conversation_extra_system_prompt: str | None = None,
) -> None:
"""Create an audio pipeline from an audio stream.
@@ -119,6 +120,7 @@ async def async_pipeline_from_audio_stream(
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
+ conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py
index 50c5176bb22..b7ab24d2f2f 100644
--- a/homeassistant/components/assist_pipeline/logbook.py
+++ b/homeassistant/components/assist_pipeline/logbook.py
@@ -7,7 +7,7 @@ from collections.abc import Callable
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, EVENT_RECORDING
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index d6a0d77ec55..1d320d79bf2 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -50,6 +50,7 @@ from homeassistant.util import (
language as language_util,
ulid as ulid_util,
)
+from homeassistant.util.hass_dict import HassKey
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
@@ -91,6 +92,8 @@ ENGINE_LANGUAGE_PAIRS = (
("tts_engine", "tts_language"),
)
+KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN)
+
def validate_language(data: dict[str, Any]) -> Any:
"""Validate language settings."""
@@ -248,7 +251,7 @@ async def async_create_default_pipeline(
The default pipeline will use the homeassistant conversation agent and the
specified stt / tts engines.
"""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_store = pipeline_data.pipeline_store
pipeline_settings = _async_resolve_default_pipeline_settings(
hass,
@@ -283,7 +286,7 @@ def _async_get_pipeline_from_conversation_entity(
@callback
def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline:
"""Get a pipeline by id or the preferred pipeline."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
if pipeline_id is None:
# A pipeline was not specified, use the preferred one
@@ -306,7 +309,7 @@ def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> P
@callback
def async_get_pipelines(hass: HomeAssistant) -> list[Pipeline]:
"""Get all pipelines."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
return list(pipeline_data.pipeline_store.data.values())
@@ -329,7 +332,7 @@ async def async_update_pipeline(
prefer_local_intents: bool | UndefinedType = UNDEFINED,
) -> None:
"""Update a pipeline."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
updates: dict[str, Any] = pipeline.to_json()
updates.pop("id")
@@ -587,7 +590,7 @@ class PipelineRun:
):
raise InvalidPipelineStagesError(self.start_stage, self.end_stage)
- pipeline_data: PipelineData = self.hass.data[DOMAIN]
+ pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
if self.pipeline.id not in pipeline_data.pipeline_debug:
pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict(
size_limit=STORED_PIPELINE_RUNS
@@ -615,7 +618,7 @@ class PipelineRun:
def process_event(self, event: PipelineEvent) -> None:
"""Log an event and call listener."""
self.event_callback(event)
- pipeline_data: PipelineData = self.hass.data[DOMAIN]
+ pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]:
# This run has been evicted from the logged pipeline runs already
return
@@ -650,7 +653,7 @@ class PipelineRun:
)
)
- pipeline_data: PipelineData = self.hass.data[DOMAIN]
+ pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_data.pipeline_runs.remove_run(self)
async def prepare_wake_word_detection(self) -> None:
@@ -1010,7 +1013,11 @@ class PipelineRun:
self.intent_agent = agent_info.id
async def recognize_intent(
- self, intent_input: str, conversation_id: str | None, device_id: str | None
+ self,
+ intent_input: str,
+ conversation_id: str | None,
+ device_id: str | None,
+ conversation_extra_system_prompt: str | None,
) -> str:
"""Run intent recognition portion of pipeline. Returns text to speak."""
if self.intent_agent is None:
@@ -1054,10 +1061,12 @@ class PipelineRun:
device_id=device_id,
language=input_language,
agent_id=self.intent_agent,
+ extra_system_prompt=conversation_extra_system_prompt,
)
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
- conversation_result: conversation.ConversationResult | None = None
+ agent_id = user_input.agent_id
+ intent_response: intent.IntentResponse | None = None
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
# Sentence triggers override conversation agent
if (
@@ -1067,14 +1076,12 @@ class PipelineRun:
)
) is not None:
# Sentence trigger matched
- trigger_response = intent.IntentResponse(
+ agent_id = "sentence_trigger"
+ intent_response = intent.IntentResponse(
self.pipeline.conversation_language
)
- trigger_response.async_set_speech(trigger_response_text)
- conversation_result = conversation.ConversationResult(
- response=trigger_response,
- conversation_id=user_input.conversation_id,
- )
+ intent_response.async_set_speech(trigger_response_text)
+
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
@@ -1082,13 +1089,30 @@ class PipelineRun:
)
):
# Local intent matched
- conversation_result = conversation.ConversationResult(
- response=intent_response,
- conversation_id=user_input.conversation_id,
- )
+ agent_id = conversation.HOME_ASSISTANT_AGENT
processed_locally = True
- if conversation_result is None:
+ # It was already handled, create response and add to chat history
+ if intent_response is not None:
+ async with conversation.async_get_chat_session(
+ self.hass, user_input
+ ) as chat_session:
+ speech: str = intent_response.speech.get("plain", {}).get(
+ "speech", ""
+ )
+ chat_session.async_add_message(
+ conversation.Content(
+ role="assistant",
+ agent_id=agent_id,
+ content=speech,
+ )
+ )
+ conversation_result = conversation.ConversationResult(
+ response=intent_response,
+ conversation_id=chat_session.conversation_id,
+ )
+
+ else:
# Fall back to pipeline conversation agent
conversation_result = await conversation.async_converse(
hass=self.hass,
@@ -1098,7 +1122,12 @@ class PipelineRun:
context=user_input.context,
language=user_input.language,
agent_id=user_input.agent_id,
+ extra_system_prompt=user_input.extra_system_prompt,
)
+ speech = conversation_result.response.speech.get("plain", {}).get(
+ "speech", ""
+ )
+
except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError(
@@ -1118,10 +1147,6 @@ class PipelineRun:
)
)
- speech: str = conversation_result.response.speech.get("plain", {}).get(
- "speech", ""
- )
-
return speech
async def prepare_text_to_speech(self) -> None:
@@ -1222,7 +1247,7 @@ class PipelineRun:
return
# Forward to device audio capture
- pipeline_data: PipelineData = self.hass.data[DOMAIN]
+ pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
audio_queue = pipeline_data.device_audio_queues.get(self._device_id)
if audio_queue is None:
return
@@ -1401,8 +1426,13 @@ class PipelineInput:
"""Input for text-to-speech. Required when start_stage = tts."""
conversation_id: str | None = None
+ """Identifier for the conversation."""
+
+ conversation_extra_system_prompt: str | None = None
+ """Extra prompt information for the conversation agent."""
device_id: str | None = None
+ """Identifier of the device that is processing the input/output of the pipeline."""
async def execute(self) -> None:
"""Run pipeline."""
@@ -1462,9 +1492,9 @@ class PipelineInput:
if stt_audio_buffer:
# 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[EnhancedAudioChunk]
- ):
+ async def buffer_then_audio_stream() -> AsyncGenerator[
+ EnhancedAudioChunk
+ ]:
# Buffered audio
for chunk in stt_audio_buffer:
yield chunk
@@ -1492,6 +1522,7 @@ class PipelineInput:
intent_input,
self.conversation_id,
self.device_id,
+ self.conversation_extra_system_prompt,
)
if tts_input.strip():
current_stage = PipelineStage.TTS
@@ -1873,7 +1904,7 @@ class PipelineStore(Store[SerializedPipelineStorageCollection]):
return old_data
-@singleton(DOMAIN)
+@singleton(KEY_ASSIST_PIPELINE, async_=True)
async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
"""Set up the pipeline storage collection."""
pipeline_store = PipelineStorageCollection(
diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py
index c7e4846aad7..a590f30fc7a 100644
--- a/homeassistant/components/assist_pipeline/select.py
+++ b/homeassistant/components/assist_pipeline/select.py
@@ -9,8 +9,8 @@ from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection, entity_registry as er, restore_state
-from .const import DOMAIN, OPTION_PREFERRED
-from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection
+from .const import OPTION_PREFERRED
+from .pipeline import KEY_ASSIST_PIPELINE, AssistDevice
from .vad import VadSensitivity
@@ -30,7 +30,7 @@ def get_chosen_pipeline(
if state is None or state.state == OPTION_PREFERRED:
return None
- pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store
+ pipeline_store = hass.data[KEY_ASSIST_PIPELINE].pipeline_store
return next(
(item.id for item in pipeline_store.async_items() if item.name == state.state),
None,
@@ -80,7 +80,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
- pipeline_data: PipelineData = self.hass.data[DOMAIN]
+ pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_store = pipeline_data.pipeline_store
self.async_on_remove(
pipeline_store.async_add_change_set_listener(self._pipelines_updated)
@@ -116,9 +116,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
@callback
def _update_options(self) -> None:
"""Handle pipeline update."""
- pipeline_store: PipelineStorageCollection = self.hass.data[
- DOMAIN
- ].pipeline_store
+ pipeline_store = self.hass.data[KEY_ASSIST_PIPELINE].pipeline_store
options = [OPTION_PREFERRED]
options.extend(sorted(item.name for item in pipeline_store.async_items()))
self._attr_options = options
diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py
index c96af655589..69f917fcf83 100644
--- a/homeassistant/components/assist_pipeline/websocket_api.py
+++ b/homeassistant/components/assist_pipeline/websocket_api.py
@@ -1,9 +1,6 @@
"""Assist pipeline Websocket API."""
import asyncio
-
-# Suppressing disable=deprecated-module is needed for Python 3.11
-import audioop # pylint: disable=deprecated-module
import base64
from collections.abc import AsyncGenerator, Callable
import contextlib
@@ -11,6 +8,7 @@ import logging
import math
from typing import Any, Final
+import audioop # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
@@ -22,7 +20,6 @@ from homeassistant.util import language as language_util
from .const import (
DEFAULT_PIPELINE_TIMEOUT,
DEFAULT_WAKE_WORD_TIMEOUT,
- DOMAIN,
EVENT_RECORDING,
SAMPLE_CHANNELS,
SAMPLE_RATE,
@@ -30,9 +27,9 @@ from .const import (
)
from .error import PipelineNotFound
from .pipeline import (
+ KEY_ASSIST_PIPELINE,
AudioSettings,
DeviceAudioQueue,
- PipelineData,
PipelineError,
PipelineEvent,
PipelineEventType,
@@ -284,7 +281,7 @@ def websocket_list_runs(
msg: dict[str, Any],
) -> None:
"""List pipeline runs for which debug data is available."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_id = msg["pipeline_id"]
if pipeline_id not in pipeline_data.pipeline_debug:
@@ -320,7 +317,7 @@ def websocket_list_devices(
msg: dict[str, Any],
) -> None:
"""List assist devices."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
ent_reg = er.async_get(hass)
connection.send_result(
msg["id"],
@@ -351,7 +348,7 @@ def websocket_get_run(
msg: dict[str, Any],
) -> None:
"""Get debug data for a pipeline run."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_id = msg["pipeline_id"]
pipeline_run_id = msg["pipeline_run_id"]
@@ -456,7 +453,7 @@ async def websocket_device_capture(
msg: dict[str, Any],
) -> None:
"""Capture raw audio from a satellite device and forward to client."""
- pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
device_id = msg["device_id"]
# Number of seconds to record audio in wall clock time
diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py
index dd940e8cdbe..038ff517264 100644
--- a/homeassistant/components/assist_satellite/__init__.py
+++ b/homeassistant/components/assist_satellite/__init__.py
@@ -30,8 +30,8 @@ from .websocket_api import async_register_websocket_api
__all__ = [
"DOMAIN",
"AssistSatelliteAnnouncement",
- "AssistSatelliteEntity",
"AssistSatelliteConfiguration",
+ "AssistSatelliteEntity",
"AssistSatelliteEntityDescription",
"AssistSatelliteEntityFeature",
"AssistSatelliteWakeWord",
@@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_internal_announce",
[AssistSatelliteEntityFeature.ANNOUNCE],
)
+ component.async_register_entity_service(
+ "start_conversation",
+ vol.All(
+ cv.make_entity_service_schema(
+ {
+ vol.Optional("start_message"): str,
+ vol.Optional("start_media_id"): str,
+ vol.Optional("extra_system_prompt"): str,
+ }
+ ),
+ cv.has_at_least_one_key("start_message", "start_media_id"),
+ ),
+ "async_internal_start_conversation",
+ [AssistSatelliteEntityFeature.START_CONVERSATION],
+ )
hass.data[CONNECTION_TEST_DATA] = {}
async_register_websocket_api(hass)
hass.http.register_view(ConnectionTestView())
diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py
index 61ac7ecb39d..f7ac7e524b4 100644
--- a/homeassistant/components/assist_satellite/const.py
+++ b/homeassistant/components/assist_satellite/const.py
@@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag):
ANNOUNCE = 1
"""Device supports remotely triggered announcements."""
+
+ START_CONVERSATION = 2
+ """Device supports starting conversations."""
diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py
index ba8b54f7da2..0229e0358b1 100644
--- a/homeassistant/components/assist_satellite/entity.py
+++ b/homeassistant/components/assist_satellite/entity.py
@@ -10,7 +10,7 @@ import logging
import time
from typing import Any, Final, Literal, final
-from homeassistant.components import media_source, stt, tts
+from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
OPTION_PREFERRED,
AudioSettings,
@@ -27,6 +27,7 @@ from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.core import Context, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity
from homeassistant.helpers.entity import EntityDescription
@@ -96,7 +97,11 @@ class AssistSatelliteAnnouncement:
media_id: str
"""Media ID to be played."""
+ original_media_id: str
+ """The raw media ID before processing."""
+
media_id_source: Literal["url", "media_id", "tts"]
+ """Source of the media ID."""
class AssistSatelliteEntity(entity.Entity):
@@ -113,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity):
_run_has_tts: bool = False
_is_announcing = False
+ _extra_system_prompt: str | None = None
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
_attr_tts_options: dict[str, Any] | None = None
_pipeline_task: asyncio.Task | None = None
@@ -187,47 +193,10 @@ class AssistSatelliteEntity(entity.Entity):
"""
await self._cancel_running_pipeline()
- media_id_source: Literal["url", "media_id", "tts"] | None = None
-
if message is None:
message = ""
- if not media_id:
- media_id_source = "tts"
- # Synthesize audio and get URL
- pipeline_id = self._resolve_pipeline()
- pipeline = async_get_pipeline(self.hass, pipeline_id)
-
- tts_options: dict[str, Any] = {}
- if pipeline.tts_voice is not None:
- tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
-
- if self.tts_options is not None:
- tts_options.update(self.tts_options)
-
- media_id = tts_generate_media_source_id(
- self.hass,
- message,
- engine=pipeline.tts_engine,
- language=pipeline.tts_language,
- options=tts_options,
- )
-
- if media_source.is_media_source_id(media_id):
- if not media_id_source:
- media_id_source = "media_id"
- media = await media_source.async_resolve_media(
- self.hass,
- media_id,
- None,
- )
- media_id = media.url
-
- if not media_id_source:
- media_id_source = "url"
-
- # Resolve to full URL
- media_id = async_process_play_media_url(self.hass, media_id)
+ announcement = await self._resolve_announcement_media_id(message, media_id)
if self._is_announcing:
raise SatelliteBusyError
@@ -237,9 +206,7 @@ class AssistSatelliteEntity(entity.Entity):
try:
# Block until announcement is finished
- await self.async_announce(
- AssistSatelliteAnnouncement(message, media_id, media_id_source)
- )
+ await self.async_announce(announcement)
finally:
self._is_announcing = False
self._set_state(AssistSatelliteState.IDLE)
@@ -251,6 +218,59 @@ class AssistSatelliteEntity(entity.Entity):
"""
raise NotImplementedError
+ async def async_internal_start_conversation(
+ self,
+ start_message: str | None = None,
+ start_media_id: str | None = None,
+ extra_system_prompt: str | None = None,
+ ) -> None:
+ """Start a conversation from the satellite.
+
+ If start_media_id is not provided, message is synthesized to
+ audio with the selected pipeline.
+
+ If start_media_id is provided, it is played directly. It is possible
+ to omit the message and the satellite will not show any text.
+
+ Calls async_start_conversation.
+ """
+ await self._cancel_running_pipeline()
+
+ # The Home Assistant built-in agent doesn't support conversations.
+ pipeline = async_get_pipeline(self.hass, self._resolve_pipeline())
+ if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT:
+ raise HomeAssistantError(
+ "Built-in conversation agent does not support starting conversations"
+ )
+
+ if start_message is None:
+ start_message = ""
+
+ announcement = await self._resolve_announcement_media_id(
+ start_message, start_media_id
+ )
+
+ if self._is_announcing:
+ raise SatelliteBusyError
+
+ self._is_announcing = True
+ # Provide our start info to the LLM so it understands context of incoming message
+ if extra_system_prompt is not None:
+ self._extra_system_prompt = extra_system_prompt
+ else:
+ self._extra_system_prompt = start_message or None
+
+ try:
+ await self.async_start_conversation(announcement)
+ finally:
+ self._is_announcing = False
+
+ async def async_start_conversation(
+ self, start_announcement: AssistSatelliteAnnouncement
+ ) -> None:
+ """Start a conversation from the satellite."""
+ raise NotImplementedError
+
async def async_accept_pipeline_from_satellite(
self,
audio_stream: AsyncIterable[bytes],
@@ -261,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity):
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline()
+ # Consume system prompt in first pipeline
+ extra_system_prompt = self._extra_system_prompt
+ self._extra_system_prompt = None
+
if self._wake_word_intercept_future and start_stage in (
PipelineStage.WAKE_WORD,
PipelineStage.STT,
@@ -337,6 +361,7 @@ class AssistSatelliteEntity(entity.Entity):
),
start_stage=start_stage,
end_stage=end_stage,
+ conversation_extra_system_prompt=extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
@@ -428,3 +453,54 @@ class AssistSatelliteEntity(entity.Entity):
vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state)
return vad.VadSensitivity.to_seconds(vad_sensitivity)
+
+ async def _resolve_announcement_media_id(
+ self, message: str, media_id: str | None
+ ) -> AssistSatelliteAnnouncement:
+ """Resolve the media ID."""
+ media_id_source: Literal["url", "media_id", "tts"] | None = None
+
+ if media_id:
+ original_media_id = media_id
+
+ else:
+ media_id_source = "tts"
+ # Synthesize audio and get URL
+ pipeline_id = self._resolve_pipeline()
+ pipeline = async_get_pipeline(self.hass, pipeline_id)
+
+ tts_options: dict[str, Any] = {}
+ if pipeline.tts_voice is not None:
+ tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
+
+ if self.tts_options is not None:
+ tts_options.update(self.tts_options)
+
+ media_id = tts_generate_media_source_id(
+ self.hass,
+ message,
+ engine=pipeline.tts_engine,
+ language=pipeline.tts_language,
+ options=tts_options,
+ )
+ original_media_id = media_id
+
+ if media_source.is_media_source_id(media_id):
+ if not media_id_source:
+ media_id_source = "media_id"
+ media = await media_source.async_resolve_media(
+ self.hass,
+ media_id,
+ None,
+ )
+ media_id = media.url
+
+ if not media_id_source:
+ media_id_source = "url"
+
+ # Resolve to full URL
+ media_id = async_process_play_media_url(self.hass, media_id)
+
+ return AssistSatelliteAnnouncement(
+ message, media_id, original_media_id, media_id_source
+ )
diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json
index a98c3aefc5b..1ed29541621 100644
--- a/homeassistant/components/assist_satellite/icons.json
+++ b/homeassistant/components/assist_satellite/icons.json
@@ -7,6 +7,9 @@
"services": {
"announce": {
"service": "mdi:bullhorn"
+ },
+ "start_conversation": {
+ "service": "mdi:forum"
}
}
}
diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py
new file mode 100644
index 00000000000..75396cf138f
--- /dev/null
+++ b/homeassistant/components/assist_satellite/intent.py
@@ -0,0 +1,69 @@
+"""Assist Satellite intents."""
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er, intent
+
+from .const import DOMAIN, AssistSatelliteEntityFeature
+
+
+async def async_setup_intents(hass: HomeAssistant) -> None:
+ """Set up the intents."""
+ intent.async_register(hass, BroadcastIntentHandler())
+
+
+class BroadcastIntentHandler(intent.IntentHandler):
+ """Broadcast a message."""
+
+ intent_type = intent.INTENT_BROADCAST
+ description = "Broadcast a message through the home"
+
+ @property
+ def slot_schema(self) -> dict | None:
+ """Return a slot schema."""
+ return {vol.Required("message"): str}
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Broadcast a message."""
+ hass = intent_obj.hass
+ ent_reg = er.async_get(hass)
+
+ # Find all assist satellite entities that are not the one invoking the intent
+ entities = {
+ entity: entry
+ for entity in hass.states.async_entity_ids(DOMAIN)
+ if (entry := ent_reg.async_get(entity))
+ and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE
+ }
+
+ if intent_obj.device_id:
+ entities = {
+ entity: entry
+ for entity, entry in entities.items()
+ if entry.device_id != intent_obj.device_id
+ }
+
+ await hass.services.async_call(
+ DOMAIN,
+ "announce",
+ {"message": intent_obj.slots["message"]["value"]},
+ blocking=True,
+ context=intent_obj.context,
+ target={"entity_id": list(entities)},
+ )
+
+ response = intent_obj.create_response()
+ response.async_set_speech("Done")
+ response.response_type = intent.IntentResponseType.ACTION_DONE
+ response.async_set_results(
+ success_results=[
+ intent.IntentResponseTarget(
+ type=intent.IntentResponseTargetType.ENTITY,
+ id=entity,
+ name=state.name if (state := hass.states.get(entity)) else entity,
+ )
+ for entity in entities
+ ]
+ )
+ return response
diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml
index e7fefc4705f..89a20ada6f3 100644
--- a/homeassistant/components/assist_satellite/services.yaml
+++ b/homeassistant/components/assist_satellite/services.yaml
@@ -14,3 +14,23 @@ announce:
required: false
selector:
text:
+start_conversation:
+ target:
+ entity:
+ domain: assist_satellite
+ supported_features:
+ - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
+ fields:
+ start_message:
+ required: false
+ example: "You left the lights on in the living room. Turn them off?"
+ selector:
+ text:
+ start_media_id:
+ required: false
+ selector:
+ text:
+ extra_system_prompt:
+ required: false
+ selector:
+ text:
diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json
index 7f1426ef529..e83f4666b5d 100644
--- a/homeassistant/components/assist_satellite/strings.json
+++ b/homeassistant/components/assist_satellite/strings.json
@@ -25,6 +25,24 @@
"description": "The media ID to announce instead of using text-to-speech."
}
}
+ },
+ "start_conversation": {
+ "name": "Start Conversation",
+ "description": "Start a conversation from a satellite.",
+ "fields": {
+ "start_message": {
+ "name": "Message",
+ "description": "The message to start with."
+ },
+ "start_media_id": {
+ "name": "Media ID",
+ "description": "The media ID to start with instead of using text-to-speech."
+ },
+ "extra_system_prompt": {
+ "name": "Extra system prompt",
+ "description": "Provide background information to the AI about the request."
+ }
+ }
}
}
}
diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py
index c81648c6ee3..6cd7af2bbdb 100644
--- a/homeassistant/components/assist_satellite/websocket_api.py
+++ b/homeassistant/components/assist_satellite/websocket_api.py
@@ -10,7 +10,6 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.util import uuid as uuid_util
from .connection_test import CONNECTION_TEST_URL_BASE
@@ -20,7 +19,6 @@ from .const import (
DOMAIN,
AssistSatelliteEntityFeature,
)
-from .entity import AssistSatelliteEntity
CONNECTION_TEST_TIMEOUT = 30
@@ -167,7 +165,7 @@ async def websocket_test_connection(
Send an announcement to the device with a special media id.
"""
- component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
+ component = hass.data[DATA_COMPONENT]
satellite = component.get_entity(msg["entity_id"])
if satellite is None:
connection.send_error(
diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py
index 39b18089284..30afab16011 100644
--- a/homeassistant/components/aten_pe/switch.py
+++ b/homeassistant/components/aten_pe/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py
index fd8250e899f..a1254c1ff49 100644
--- a/homeassistant/components/atome/sensor.py
+++ b/homeassistant/components/atome/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py
index fe5d90371ad..c681cc98808 100644
--- a/homeassistant/components/august/lock.py
+++ b/homeassistant/components/august/lock.py
@@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import AugustConfigEntry, AugustData
from .entity import AugustEntity
diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py
index c9efb081a01..6c85f5b7f55 100644
--- a/homeassistant/components/auth/mfa_setup_flow.py
+++ b/homeassistant/components/auth/mfa_setup_flow.py
@@ -12,7 +12,7 @@ from homeassistant import data_entry_flow
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowContext
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index bd8af526d75..856060f8c75 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -9,7 +9,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Protocol, cast
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -48,8 +48,7 @@ from homeassistant.core import (
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
-from homeassistant.helpers import condition
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
@@ -636,9 +635,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
alias = ""
if "trigger" in run_variables:
if "description" in run_variables["trigger"]:
- reason = f' by {run_variables["trigger"]["description"]}'
+ reason = f" by {run_variables['trigger']['description']}"
if "alias" in run_variables["trigger"]:
- alias = f' trigger \'{run_variables["trigger"]["alias"]}\''
+ alias = f" trigger '{run_variables['trigger']['alias']}'"
self._logger.debug("Automation%s triggered%s", alias, reason)
# Create a new context referring to the old context.
diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py
index 48471b41633..ec39a6f371c 100644
--- a/homeassistant/components/avea/light.py
+++ b/homeassistant/components/avea/light.py
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
def setup_platform(
diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py
index 687405e3064..5b9371e0e2b 100644
--- a/homeassistant/components/avion/light.py
+++ b/homeassistant/components/avion/light.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py
index 88985b0db10..429187e1f5b 100644
--- a/homeassistant/components/awair/config_flow.py
+++ b/homeassistant/components/awair/config_flow.py
@@ -11,11 +11,12 @@ from python_awair.exceptions import AuthError, AwairError
from python_awair.user import AwairUser
import voluptuous as vol
-from homeassistant.components import onboarding, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE, CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
@@ -29,7 +30,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
host: str
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py
index 592b1e2d41f..9f801882387 100644
--- a/homeassistant/components/axis/config_flow.py
+++ b/homeassistant/components/axis/config_flow.py
@@ -10,7 +10,6 @@ from urllib.parse import urlsplit
import voluptuous as vol
-from homeassistant.components import dhcp, ssdp, zeroconf
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
@@ -32,6 +31,14 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_PRESENTATION_URL,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from homeassistant.util.network import is_link_local
@@ -190,7 +197,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
return await self.async_step_user()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a DHCP discovered Axis device."""
return await self._process_discovered_device(
@@ -203,21 +210,21 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a SSDP discovered Axis device."""
- url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL])
+ url = urlsplit(discovery_info.upnp[ATTR_UPNP_PRESENTATION_URL])
return await self._process_discovered_device(
{
CONF_HOST: url.hostname,
- CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]),
- CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}",
+ CONF_MAC: format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]),
+ CONF_NAME: f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]}",
CONF_PORT: url.port,
}
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a Zeroconf discovered Axis device."""
return await self._process_discovered_device(
diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json
index c8ec158a844..b4734ad2262 100644
--- a/homeassistant/components/azure_data_explorer/strings.json
+++ b/homeassistant/components/azure_data_explorer/strings.json
@@ -2,10 +2,10 @@
"config": {
"step": {
"user": {
- "title": "Setup your Azure Data Explorer integration",
+ "title": "Set up Azure Data Explorer",
"description": "Enter connection details",
"data": {
- "cluster_ingest_uri": "Cluster Ingest URI",
+ "cluster_ingest_uri": "Cluster ingestion URI",
"authority_id": "Authority ID",
"client_id": "Client ID",
"client_secret": "Client secret",
@@ -14,7 +14,7 @@
"use_queued_ingestion": "Use queued ingestion"
},
"data_description": {
- "cluster_ingest_uri": "Ingest-URI of the cluster",
+ "cluster_ingest_uri": "Ingestion URI of the cluster",
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
}
}
diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py
index bc9d34e728e..abe6cdfe15f 100644
--- a/homeassistant/components/azure_event_hub/__init__.py
+++ b/homeassistant/components/azure_event_hub/__init__.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import MATCH_ALL
from homeassistant.core import Event, HomeAssistant, State
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.json import JSONEncoder
diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json
index d17c4a385c0..8ec559ac8b7 100644
--- a/homeassistant/components/azure_event_hub/strings.json
+++ b/homeassistant/components/azure_event_hub/strings.json
@@ -2,26 +2,26 @@
"config": {
"step": {
"user": {
- "title": "Set up your Azure Event Hub integration",
+ "title": "Set up Azure Event Hub",
"data": {
- "event_hub_instance_name": "Event Hub Instance Name",
- "use_connection_string": "Use Connection String"
+ "event_hub_instance_name": "Event Hub instance name",
+ "use_connection_string": "Use connection string"
}
},
"conn_string": {
- "title": "Connection String method",
+ "title": "Connection string method",
"description": "Please enter the connection string for: {event_hub_instance_name}",
"data": {
- "event_hub_connection_string": "Event Hub Connection String"
+ "event_hub_connection_string": "Event Hub connection string"
}
},
"sas": {
- "title": "SAS Credentials method",
+ "title": "SAS credentials method",
"description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}",
"data": {
- "event_hub_namespace": "Event Hub Namespace",
- "event_hub_sas_policy": "Event Hub SAS Policy",
- "event_hub_sas_key": "Event Hub SAS Key"
+ "event_hub_namespace": "Event Hub namespace",
+ "event_hub_sas_policy": "Event Hub SAS policy",
+ "event_hub_sas_key": "Event Hub SAS key"
}
}
},
@@ -38,7 +38,7 @@
"options": {
"step": {
"init": {
- "title": "Options for the Azure Event Hub.",
+ "title": "Options for Azure Event Hub.",
"data": {
"send_interval": "Interval between sending batches to the hub."
}
diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py
index a0aa36804c3..83eb8076fef 100644
--- a/homeassistant/components/azure_service_bus/notify.py
+++ b/homeassistant/components/azure_service_bus/notify.py
@@ -23,7 +23,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
CONF_CONNECTION_STRING = "connection_string"
diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py
index 00b226a9fee..71a4f5ea41a 100644
--- a/homeassistant/components/backup/__init__.py
+++ b/homeassistant/components/backup/__init__.py
@@ -1,6 +1,7 @@
"""The Backup integration."""
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@@ -19,35 +20,54 @@ from .const import DATA_MANAGER, DOMAIN
from .http import async_register_http_views
from .manager import (
BackupManager,
+ BackupManagerError,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
+ CreateBackupStage,
+ CreateBackupState,
+ IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
+ RestoreBackupEvent,
+ RestoreBackupStage,
+ RestoreBackupState,
WrittenBackup,
)
-from .models import AddonInfo, AgentBackup, Folder
+from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
+from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
__all__ = [
"AddonInfo",
"AgentBackup",
- "ManagerBackup",
"BackupAgent",
"BackupAgentError",
"BackupAgentPlatformProtocol",
+ "BackupManagerError",
+ "BackupNotFound",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
+ "CreateBackupStage",
+ "CreateBackupState",
"Folder",
+ "IdleEvent",
"IncorrectPasswordError",
"LocalBackupAgent",
+ "ManagerBackup",
"NewBackup",
+ "RestoreBackupEvent",
+ "RestoreBackupStage",
+ "RestoreBackupState",
"WrittenBackup",
+ "async_get_manager",
+ "suggested_filename",
+ "suggested_filename_from_name_date",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -86,9 +106,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
password=None,
)
+ async def async_handle_create_automatic_service(call: ServiceCall) -> None:
+ """Service handler for creating automatic backups."""
+ await backup_manager.async_create_automatic_backup()
+
if not with_hassio:
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
+ hass.services.async_register(
+ DOMAIN, "create_automatic", async_handle_create_automatic_service
+ )
async_register_http_views(hass)
return True
+
+
+@callback
+def async_get_manager(hass: HomeAssistant) -> BackupManager:
+ """Get the backup manager instance.
+
+ Raises HomeAssistantError if the backup integration is not available.
+ """
+ if DATA_MANAGER not in hass.data:
+ raise HomeAssistantError("Backup integration is not available")
+
+ return hass.data[DATA_MANAGER]
diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py
index 44bc9b298e8..9530f386c7b 100644
--- a/homeassistant/components/backup/agent.py
+++ b/homeassistant/components/backup/agent.py
@@ -7,21 +7,17 @@ from collections.abc import AsyncIterator, Callable, Coroutine
from pathlib import Path
from typing import Any, Protocol
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import HomeAssistantError
-from .models import AgentBackup
-
-
-class BackupAgentError(HomeAssistantError):
- """Base class for backup agent errors."""
+from .models import AgentBackup, BackupAgentError
class BackupAgentUnreachableError(BackupAgentError):
"""Raised when the agent can't reach its API."""
+ error_code = "backup_agent_unreachable"
_message = "The backup agent is unreachable."
@@ -30,11 +26,12 @@ class BackupAgent(abc.ABC):
domain: str
name: str
+ unique_id: str
@cached_property
def agent_id(self) -> str:
"""Return the agent_id."""
- return f"{self.domain}.{self.name}"
+ return f"{self.domain}.{self.unique_id}"
@abc.abstractmethod
async def async_download_backup(
@@ -91,11 +88,16 @@ class LocalBackupAgent(BackupAgent):
@abc.abstractmethod
def get_backup_path(self, backup_id: str) -> Path:
- """Return the local path to a backup.
+ """Return the local path to an existing backup.
The method should return the path to the backup file with the specified id.
+ Raises BackupAgentError if the backup does not exist.
"""
+ @abc.abstractmethod
+ def get_new_backup_path(self, backup: AgentBackup) -> Path:
+ """Return the local path to a new backup."""
+
class BackupAgentPlatformProtocol(Protocol):
"""Define the format of backup platforms which implement backup agents."""
diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py
index ef4924161c2..c3a46a6ab1f 100644
--- a/homeassistant/components/backup/backup.py
+++ b/homeassistant/components/backup/backup.py
@@ -13,8 +13,8 @@ from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER
-from .models import AgentBackup
-from .util import read_backup
+from .models import AgentBackup, BackupNotFound
+from .util import read_backup, suggested_filename
async def async_get_backup_agents(
@@ -32,13 +32,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
domain = DOMAIN
name = "local"
+ unique_id = "local"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._backup_dir = Path(hass.config.path("backups"))
- self._backups: dict[str, AgentBackup] = {}
+ self._backups: dict[str, tuple[AgentBackup, Path]] = {}
self._loaded_backups = False
async def _load_backups(self) -> None:
@@ -48,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
self._backups = backups
self._loaded_backups = True
- def _read_backups(self) -> dict[str, AgentBackup]:
+ def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
"""Read backups from disk."""
- backups: dict[str, AgentBackup] = {}
+ backups: dict[str, tuple[AgentBackup, Path]] = {}
for backup_path in self._backup_dir.glob("*.tar"):
try:
backup = read_backup(backup_path)
- backups[backup.backup_id] = backup
+ backups[backup.backup_id] = (backup, backup_path)
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@@ -75,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
**kwargs: Any,
) -> None:
"""Upload a backup."""
- self._backups[backup.backup_id] = backup
+ self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
if not self._loaded_backups:
await self._load_backups()
- return list(self._backups.values())
+ return [backup for backup, _ in self._backups.values()]
async def async_get_backup(
self,
@@ -92,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
if not self._loaded_backups:
await self._load_backups()
- if not (backup := self._backups.get(backup_id)):
+ if backup_id not in self._backups:
return None
- backup_path = self.get_backup_path(backup_id)
+ backup, backup_path = self._backups[backup_id]
if not await self._hass.async_add_executor_job(backup_path.exists):
LOGGER.debug(
(
@@ -111,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent):
return backup
def get_backup_path(self, backup_id: str) -> Path:
- """Return the local path to a backup."""
- return self._backup_dir / f"{backup_id}.tar"
+ """Return the local path to an existing backup.
+
+ Raises BackupAgentError if the backup does not exist.
+ """
+ try:
+ return self._backups[backup_id][1]
+ except KeyError as err:
+ raise BackupNotFound(f"Backup {backup_id} does not exist") from err
+
+ def get_new_backup_path(self, backup: AgentBackup) -> Path:
+ """Return the local path to a new backup."""
+ return self._backup_dir / suggested_filename(backup)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
- if await self.async_get_backup(backup_id) is None:
- return
+ if not self._loaded_backups:
+ await self._load_backups()
- backup_path = self.get_backup_path(backup_id)
+ try:
+ backup_path = self.get_backup_path(backup_id)
+ except BackupNotFound:
+ return
await self._hass.async_add_executor_job(backup_path.unlink, True)
LOGGER.debug("Deleted backup located at %s", backup_path)
self._backups.pop(backup_id)
diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py
index 7c40792aec5..4d0cd82bc44 100644
--- a/homeassistant/components/backup/config.py
+++ b/homeassistant/components/backup/config.py
@@ -2,9 +2,8 @@
from __future__ import annotations
-import asyncio
-from collections.abc import Callable
from dataclasses import dataclass, field, replace
+import datetime as dt
from datetime import datetime, timedelta
from enum import StrEnum
import random
@@ -23,11 +22,13 @@ from .models import BackupManagerError, Folder
if TYPE_CHECKING:
from .manager import BackupManager, ManagerBackup
-# The time of the automatic backup event should be compatible with
-# the time of the recorder's nightly job which runs at 04:12.
-# Run the backup at 04:45.
-CRON_PATTERN_DAILY = "45 4 * * *"
-CRON_PATTERN_WEEKLY = "45 4 * * {}"
+CRON_PATTERN_DAILY = "{m} {h} * * *"
+CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
+
+# The default time for automatic backups to run is at 04:45.
+# This time is chosen to be compatible with the time of the recorder's
+# nightly job which runs at 04:12.
+DEFAULT_BACKUP_TIME = dt.time(4, 45)
# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
@@ -37,6 +38,7 @@ BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
+ agents: dict[str, StoredAgentConfig]
create_backup: StoredCreateBackupConfig
last_attempted_automatic_backup: str | None
last_completed_automatic_backup: str | None
@@ -48,6 +50,7 @@ class StoredBackupConfig(TypedDict):
class BackupConfigData:
"""Represent loaded backup config data."""
+ agents: dict[str, AgentConfig]
create_backup: CreateBackupConfig
last_attempted_automatic_backup: datetime | None = None
last_completed_automatic_backup: datetime | None = None
@@ -74,7 +77,17 @@ class BackupConfigData:
else:
last_completed = None
+ if time_str := data["schedule"]["time"]:
+ time = dt_util.parse_time(time_str)
+ else:
+ time = None
+ days = [Day(day) for day in data["schedule"]["days"]]
+
return cls(
+ agents={
+ agent_id: AgentConfig(protected=agent_data["protected"])
+ for agent_id, agent_data in data["agents"].items()
+ },
create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"],
include_addons=data["create_backup"]["include_addons"],
@@ -90,7 +103,12 @@ class BackupConfigData:
copies=retention["copies"],
days=retention["days"],
),
- schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])),
+ schedule=BackupSchedule(
+ days=days,
+ recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]),
+ state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)),
+ time=time,
+ ),
)
def to_dict(self) -> StoredBackupConfig:
@@ -106,6 +124,9 @@ class BackupConfigData:
last_completed = None
return StoredBackupConfig(
+ agents={
+ agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
+ },
create_backup=self.create_backup.to_dict(),
last_attempted_automatic_backup=last_attempted,
last_completed_automatic_backup=last_completed,
@@ -120,6 +141,7 @@ class BackupConfig:
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
"""Initialize backup config."""
self.data = BackupConfigData(
+ agents={},
create_backup=CreateBackupConfig(),
retention=RetentionConfig(),
schedule=BackupSchedule(),
@@ -135,11 +157,20 @@ class BackupConfig:
async def update(
self,
*,
+ agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
- schedule: ScheduleState | UndefinedType = UNDEFINED,
+ schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
) -> None:
"""Update config."""
+ if agents is not UNDEFINED:
+ for agent_id, agent_config in agents.items():
+ if agent_id not in self.data.agents:
+ self.data.agents[agent_id] = AgentConfig(**agent_config)
+ else:
+ self.data.agents[agent_id] = replace(
+ self.data.agents[agent_id], **agent_config
+ )
if create_backup is not UNDEFINED:
self.data.create_backup = replace(self.data.create_backup, **create_backup)
if retention is not UNDEFINED:
@@ -148,7 +179,7 @@ class BackupConfig:
self.data.retention = new_retention
self.data.retention.apply(self._manager)
if schedule is not UNDEFINED:
- new_schedule = BackupSchedule(state=schedule)
+ new_schedule = BackupSchedule(**schedule)
if new_schedule.to_dict() != self.data.schedule.to_dict():
self.data.schedule = new_schedule
self.data.schedule.apply(self._manager)
@@ -156,6 +187,31 @@ class BackupConfig:
self._manager.store.save()
+@dataclass(kw_only=True)
+class AgentConfig:
+ """Represent the config for an agent."""
+
+ protected: bool
+
+ def to_dict(self) -> StoredAgentConfig:
+ """Convert agent config to a dict."""
+ return {
+ "protected": self.protected,
+ }
+
+
+class StoredAgentConfig(TypedDict):
+ """Represent the stored config for an agent."""
+
+ protected: bool
+
+
+class AgentParametersDict(TypedDict, total=False):
+ """Represent the parameters for an agent."""
+
+ protected: bool
+
+
@dataclass(kw_only=True)
class RetentionConfig:
"""Represent the backup retention configuration."""
@@ -194,7 +250,7 @@ class RetentionConfig:
"""Delete backups older than days."""
self._schedule_next(manager)
- def _backups_filter(
+ def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return backups older than days to delete."""
@@ -211,7 +267,9 @@ class RetentionConfig:
< now
}
- await _delete_filtered_backups(manager, _backups_filter)
+ await manager.async_delete_filtered_backups(
+ include_filter=_automatic_backups_filter, delete_filter=_delete_filter
+ )
manager.remove_next_delete_event = async_call_later(
manager.hass, timedelta(days=1), _delete_backups
@@ -242,11 +300,46 @@ class RetentionParametersDict(TypedDict, total=False):
class StoredBackupSchedule(TypedDict):
"""Represent the stored backup schedule configuration."""
+ days: list[Day]
+ recurrence: ScheduleRecurrence
state: ScheduleState
+ time: str | None
+
+
+class ScheduleParametersDict(TypedDict, total=False):
+ """Represent parameters for backup schedule."""
+
+ days: list[Day]
+ recurrence: ScheduleRecurrence
+ state: ScheduleState
+ time: dt.time | None
+
+
+class Day(StrEnum):
+ """Represent the day(s) in a custom schedule recurrence."""
+
+ MONDAY = "mon"
+ TUESDAY = "tue"
+ WEDNESDAY = "wed"
+ THURSDAY = "thu"
+ FRIDAY = "fri"
+ SATURDAY = "sat"
+ SUNDAY = "sun"
+
+
+class ScheduleRecurrence(StrEnum):
+ """Represent the schedule recurrence."""
+
+ NEVER = "never"
+ DAILY = "daily"
+ CUSTOM_DAYS = "custom_days"
class ScheduleState(StrEnum):
- """Represent the schedule state."""
+ """Represent the schedule recurrence.
+
+ This is deprecated and can be remove in HA Core 2025.8.
+ """
NEVER = "never"
DAILY = "daily"
@@ -263,8 +356,15 @@ class ScheduleState(StrEnum):
class BackupSchedule:
"""Represent the backup schedule."""
+ days: list[Day] = field(default_factory=list)
+ recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER
+ # Although no longer used, state is kept for backwards compatibility.
+ # It can be removed in HA Core 2025.8.
state: ScheduleState = ScheduleState.NEVER
+ time: dt.time | None = None
cron_event: CronSim | None = field(init=False, default=None)
+ next_automatic_backup: datetime | None = field(init=False, default=None)
+ next_automatic_backup_additional = False
@callback
def apply(
@@ -273,17 +373,27 @@ class BackupSchedule:
) -> None:
"""Apply a new schedule.
- There are only three possible state types: never, daily, or weekly.
+ There are only three possible recurrence types: never, daily, or custom_days
"""
- if self.state is ScheduleState.NEVER:
+ if self.recurrence is ScheduleRecurrence.NEVER or (
+ self.recurrence is ScheduleRecurrence.CUSTOM_DAYS and not self.days
+ ):
self._unschedule_next(manager)
return
- if self.state is ScheduleState.DAILY:
- self._schedule_next(CRON_PATTERN_DAILY, manager)
- else:
+ time = self.time if self.time is not None else DEFAULT_BACKUP_TIME
+ if self.recurrence is ScheduleRecurrence.DAILY:
self._schedule_next(
- CRON_PATTERN_WEEKLY.format(self.state.value),
+ CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour),
+ manager,
+ )
+ else: # ScheduleRecurrence.CUSTOM_DAYS
+ self._schedule_next(
+ CRON_PATTERN_WEEKLY.format(
+ m=time.minute,
+ h=time.hour,
+ d=",".join(day.value for day in self.days),
+ ),
manager,
)
@@ -304,49 +414,59 @@ class BackupSchedule:
if next_time < now:
# schedule a backup at next daily time once
# if we missed the last scheduled backup
- cron_event = CronSim(CRON_PATTERN_DAILY, now)
+ time = self.time if self.time is not None else DEFAULT_BACKUP_TIME
+ cron_event = CronSim(
+ CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now
+ )
next_time = next(cron_event)
# reseed the cron event attribute
# add a day to the next time to avoid scheduling at the same time again
self.cron_event = CronSim(cron_pattern, now + timedelta(days=1))
+ # Compare the computed next time with the next time from the cron pattern
+ # to determine if an additional backup has been scheduled
+ cron_event_configured = CronSim(cron_pattern, now)
+ next_configured_time = next(cron_event_configured)
+ self.next_automatic_backup_additional = next_time < next_configured_time
+ else:
+ self.next_automatic_backup_additional = False
+
async def _create_backup(now: datetime) -> None:
"""Create backup."""
manager.remove_next_backup_event = None
- config_data = manager.config.data
self._schedule_next(cron_pattern, manager)
# create the backup
try:
- await manager.async_create_backup(
- agent_ids=config_data.create_backup.agent_ids,
- include_addons=config_data.create_backup.include_addons,
- include_all_addons=config_data.create_backup.include_all_addons,
- include_database=config_data.create_backup.include_database,
- include_folders=config_data.create_backup.include_folders,
- include_homeassistant=True, # always include HA
- name=config_data.create_backup.name,
- password=config_data.create_backup.password,
- with_automatic_settings=True,
- )
+ await manager.async_create_automatic_backup()
except BackupManagerError as err:
LOGGER.error("Error creating backup: %s", err)
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error creating automatic backup")
- next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
+ if self.time is None:
+ # randomize the start time of the backup by up to 60 minutes if the time is
+ # not set to avoid all backups running at the same time
+ next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
+ self.next_automatic_backup = next_time
manager.remove_next_backup_event = async_track_point_in_time(
manager.hass, _create_backup, next_time
)
def to_dict(self) -> StoredBackupSchedule:
"""Convert backup schedule to a dict."""
- return StoredBackupSchedule(state=self.state)
+ return StoredBackupSchedule(
+ days=self.days,
+ recurrence=self.recurrence,
+ state=self.state,
+ time=self.time.isoformat() if self.time else None,
+ )
@callback
def _unschedule_next(self, manager: BackupManager) -> None:
"""Unschedule the next backup."""
+ self.next_automatic_backup = None
if (remove_next_event := manager.remove_next_backup_event) is not None:
remove_next_event()
manager.remove_next_backup_event = None
@@ -401,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False):
password: str | None
-async def _delete_filtered_backups(
- manager: BackupManager,
- backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
-) -> None:
- """Delete backups parsed with a filter.
-
- :param manager: The backup manager.
- :param backup_filter: A filter that should return the backups to delete.
- """
- backups, get_agent_errors = await manager.async_get_backups()
- if get_agent_errors:
- LOGGER.debug(
- "Error getting backups; continuing anyway: %s",
- get_agent_errors,
- )
-
- # only delete backups that are created with the saved automatic settings
- backups = {
+def _automatic_backups_filter(
+ backups: dict[str, ManagerBackup],
+) -> dict[str, ManagerBackup]:
+ """Return automatic backups."""
+ return {
backup_id: backup
for backup_id, backup in backups.items()
if backup.with_automatic_settings
}
- LOGGER.debug("Total automatic backups: %s", backups)
-
- filtered_backups = backup_filter(backups)
-
- if not filtered_backups:
- return
-
- # always delete oldest backup first
- filtered_backups = dict(
- sorted(
- filtered_backups.items(),
- key=lambda backup_item: backup_item[1].date,
- )
- )
-
- if len(filtered_backups) >= len(backups):
- # Never delete the last backup.
- last_backup = filtered_backups.popitem()
- LOGGER.debug("Keeping the last backup: %s", last_backup)
-
- LOGGER.debug("Backups to delete: %s", filtered_backups)
-
- if not filtered_backups:
- return
-
- backup_ids = list(filtered_backups)
- delete_results = await asyncio.gather(
- *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups)
- )
- agent_errors = {
- backup_id: error
- for backup_id, error in zip(backup_ids, delete_results, strict=True)
- if error
- }
- if agent_errors:
- LOGGER.error(
- "Error deleting old copies: %s",
- agent_errors,
- )
-
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
"""Delete backups exceeding the configured retention count."""
- def _backups_filter(
+ def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete."""
@@ -483,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
)
- await _delete_filtered_backups(manager, _backups_filter)
+ await manager.async_delete_filtered_backups(
+ include_filter=_automatic_backups_filter, delete_filter=_delete_filter
+ )
diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py
index 73a8c8eb602..58f44d4a449 100644
--- a/homeassistant/components/backup/http.py
+++ b/homeassistant/components/backup/http.py
@@ -4,18 +4,24 @@ from __future__ import annotations
import asyncio
from http import HTTPStatus
-from typing import cast
+import threading
+from typing import IO, cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response, StreamResponse
+from multidict import istr
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import slugify
+from . import util
+from .agent import BackupAgent
from .const import DATA_MANAGER
+from .manager import BackupManager
+from .models import BackupNotFound
@callback
@@ -43,8 +49,13 @@ class DownloadBackupView(HomeAssistantView):
agent_id = request.query.getone("agent_id")
except KeyError:
return Response(status=HTTPStatus.BAD_REQUEST)
+ try:
+ password = request.query.getone("password")
+ except KeyError:
+ password = None
- manager = request.app[KEY_HASS].data[DATA_MANAGER]
+ hass = request.app[KEY_HASS]
+ manager = hass.data[DATA_MANAGER]
if agent_id not in manager.backup_agents:
return Response(status=HTTPStatus.BAD_REQUEST)
agent = manager.backup_agents[agent_id]
@@ -58,6 +69,27 @@ class DownloadBackupView(HomeAssistantView):
headers = {
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
+
+ try:
+ if not password or not backup.protected:
+ return await self._send_backup_no_password(
+ request, headers, backup_id, agent_id, agent, manager
+ )
+ return await self._send_backup_with_password(
+ hass, request, headers, backup_id, agent_id, password, agent, manager
+ )
+ except BackupNotFound:
+ return Response(status=HTTPStatus.NOT_FOUND)
+
+ async def _send_backup_no_password(
+ self,
+ request: Request,
+ headers: dict[istr, str],
+ backup_id: str,
+ agent_id: str,
+ agent: BackupAgent,
+ manager: BackupManager,
+ ) -> StreamResponse | FileResponse | Response:
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
path = local_agent.get_backup_path(backup_id)
@@ -70,15 +102,63 @@ class DownloadBackupView(HomeAssistantView):
await response.write(chunk)
return response
+ async def _send_backup_with_password(
+ self,
+ hass: HomeAssistant,
+ request: Request,
+ headers: dict[istr, str],
+ backup_id: str,
+ agent_id: str,
+ password: str,
+ agent: BackupAgent,
+ manager: BackupManager,
+ ) -> StreamResponse | FileResponse | Response:
+ reader: IO[bytes]
+ if agent_id in manager.local_backup_agents:
+ local_agent = manager.local_backup_agents[agent_id]
+ path = local_agent.get_backup_path(backup_id)
+ try:
+ reader = await hass.async_add_executor_job(open, path.as_posix(), "rb")
+ except FileNotFoundError:
+ return Response(status=HTTPStatus.NOT_FOUND)
+ else:
+ stream = await agent.async_download_backup(backup_id)
+ reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream))
+
+ worker_done_event = asyncio.Event()
+
+ def on_done(error: Exception | None) -> None:
+ """Call by the worker thread when it's done."""
+ hass.loop.call_soon_threadsafe(worker_done_event.set)
+
+ stream = util.AsyncIteratorWriter(hass)
+ worker = threading.Thread(
+ target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
+ )
+ try:
+ worker.start()
+ response = StreamResponse(status=HTTPStatus.OK, headers=headers)
+ await response.prepare(request)
+ async for chunk in stream:
+ await response.write(chunk)
+ return response
+ finally:
+ reader.close()
+ await worker_done_event.wait()
+
class UploadBackupView(HomeAssistantView):
- """Generate backup view."""
+ """Upload backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
async def post(self, request: Request) -> Response:
+ """Upload a backup file."""
+ return await self._post(request)
+
+ async def _post(self, request: Request) -> Response:
"""Upload a backup file."""
try:
agent_ids = request.query.getall("agent_id")
@@ -89,7 +169,9 @@ class UploadBackupView(HomeAssistantView):
contents = cast(BodyPartReader, await reader.next())
try:
- await manager.async_receive_backup(contents=contents, agent_ids=agent_ids)
+ backup_id = await manager.async_receive_backup(
+ contents=contents, agent_ids=agent_ids
+ )
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",
@@ -103,4 +185,4 @@ class UploadBackupView(HomeAssistantView):
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
- return Response(status=HTTPStatus.CREATED)
+ return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED)
diff --git a/homeassistant/components/backup/icons.json b/homeassistant/components/backup/icons.json
index bd5ff4a81ee..8a412f66edc 100644
--- a/homeassistant/components/backup/icons.json
+++ b/homeassistant/components/backup/icons.json
@@ -2,6 +2,9 @@
"services": {
"create": {
"service": "mdi:cloud-upload"
+ },
+ "create_automatic": {
+ "service": "mdi:cloud-upload"
}
}
}
diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py
index ba1c457561f..25393a872cc 100644
--- a/homeassistant/components/backup/manager.py
+++ b/homeassistant/components/backup/manager.py
@@ -5,32 +5,37 @@ from __future__ import annotations
import abc
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from enum import StrEnum
import hashlib
import io
+from itertools import chain
import json
-from pathlib import Path
+from pathlib import Path, PurePath
import shutil
import tarfile
import time
-from typing import TYPE_CHECKING, Any, Protocol, TypedDict
+from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
-from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key
+from homeassistant.backup_restore import (
+ RESTORE_BACKUP_FILE,
+ RESTORE_BACKUP_RESULT_FILE,
+ password_to_key,
+)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
instance_id,
integration_platform,
issue_registry as ir,
)
from homeassistant.helpers.json import json_bytes
-from homeassistant.util import dt as dt_util
+from homeassistant.util import dt as dt_util, json as json_util
+from . import util as backup_util
from .agent import (
BackupAgent,
BackupAgentError,
@@ -46,9 +51,24 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
-from .models import AgentBackup, BackupManagerError, Folder
+from .models import (
+ AgentBackup,
+ BackupError,
+ BackupManagerError,
+ BackupReaderWriterError,
+ BaseBackup,
+ Folder,
+)
from .store import BackupStore
-from .util import make_backup_dir, read_backup, validate_password
+from .util import (
+ AsyncIteratorReader,
+ DecryptedBackupStreamer,
+ EncryptedBackupStreamer,
+ make_backup_dir,
+ read_backup,
+ validate_password,
+ validate_password_stream,
+)
@dataclass(frozen=True, kw_only=True, slots=True)
@@ -59,10 +79,18 @@ class NewBackup:
@dataclass(frozen=True, kw_only=True, slots=True)
-class ManagerBackup(AgentBackup):
+class AgentBackupStatus:
+ """Agent specific backup attributes."""
+
+ protected: bool
+ size: int
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class ManagerBackup(BaseBackup):
"""Backup class."""
- agent_ids: list[str]
+ agents: dict[str, AgentBackupStatus]
failed_agent_ids: list[str]
with_automatic_settings: bool | None
@@ -140,6 +168,7 @@ class RestoreBackupState(StrEnum):
"""Receive backup state enum."""
COMPLETED = "completed"
+ CORE_RESTART = "core_restart"
FAILED = "failed"
IN_PROGRESS = "in_progress"
@@ -163,6 +192,7 @@ class CreateBackupEvent(ManagerStateEvent):
"""Backup in progress."""
manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP
+ reason: str | None
stage: CreateBackupStage | None
state: CreateBackupState
@@ -172,6 +202,7 @@ class ReceiveBackupEvent(ManagerStateEvent):
"""Backup receive."""
manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP
+ reason: str | None
stage: ReceiveBackupStage | None
state: ReceiveBackupState
@@ -181,6 +212,7 @@ class RestoreBackupEvent(ManagerStateEvent):
"""Backup restore."""
manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP
+ reason: str | None
stage: RestoreBackupStage | None
state: RestoreBackupState
@@ -210,7 +242,7 @@ class BackupReaderWriter(abc.ABC):
include_database: bool,
include_folders: list[Folder] | None,
include_homeassistant: bool,
- on_progress: Callable[[ManagerStateEvent], None],
+ on_progress: Callable[[CreateBackupEvent], None],
password: str | None,
) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
"""Create a backup."""
@@ -231,6 +263,7 @@ class BackupReaderWriter(abc.ABC):
backup_id: str,
*,
agent_id: str,
+ on_progress: Callable[[RestoreBackupEvent], None],
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
restore_addons: list[str] | None,
@@ -240,14 +273,28 @@ class BackupReaderWriter(abc.ABC):
) -> None:
"""Restore a backup."""
-
-class BackupReaderWriterError(HomeAssistantError):
- """Backup reader/writer error."""
+ @abc.abstractmethod
+ async def async_resume_restore_progress_after_restart(
+ self,
+ *,
+ on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
+ ) -> None:
+ """Get restore events after core restart."""
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
+ error_code = "password_incorrect"
+ _message = "The password provided is incorrect."
+
+
+class DecryptOnDowloadNotSupported(BackupManagerError):
+ """Raised when on-the-fly decryption is not supported."""
+
+ error_code = "decrypt_on_download_not_supported"
+ _message = "On-the-fly decryption is not supported for this backup."
+
class BackupManager:
"""Define the format that backup managers can have."""
@@ -275,6 +322,7 @@ class BackupManager:
# Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = IdleEvent()
+ self.last_non_idle_event: ManagerStateEvent | None = None
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
async def async_setup(self) -> None:
@@ -284,6 +332,10 @@ class BackupManager:
self.config.load(stored["config"])
self.known_backups.load(stored["backups"])
+ await self._reader_writer.async_resume_restore_progress_after_restart(
+ on_progress=self.async_on_backup_event
+ )
+
await self.load_platforms()
@property
@@ -413,35 +465,79 @@ class BackupManager:
backup: AgentBackup,
agent_ids: list[str],
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ password: str | None,
) -> dict[str, Exception]:
"""Upload a backup to selected agents."""
agent_errors: dict[str, Exception] = {}
LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids)
- sync_backup_results = await asyncio.gather(
- *(
- self.backup_agents[agent_id].async_upload_backup(
- open_stream=open_stream,
- backup=backup,
+ async def upload_backup_to_agent(agent_id: str) -> None:
+ """Upload backup to a single agent, and encrypt or decrypt as needed."""
+ config = self.config.data.agents.get(agent_id)
+ should_encrypt = config.protected if config else password is not None
+ streamer: DecryptedBackupStreamer | EncryptedBackupStreamer | None = None
+ if should_encrypt == backup.protected or password is None:
+ # The backup we're uploading is already in the correct state, or we
+ # don't have a password to encrypt or decrypt it
+ LOGGER.debug(
+ "Uploading backup %s to agent %s as is", backup.backup_id, agent_id
)
- for agent_id in agent_ids
- ),
+ open_stream_func = open_stream
+ _backup = backup
+ elif should_encrypt:
+ # The backup we're uploading is not encrypted, but the agent requires it
+ LOGGER.debug(
+ "Uploading encrypted backup %s to agent %s",
+ backup.backup_id,
+ agent_id,
+ )
+ streamer = EncryptedBackupStreamer(
+ self.hass, backup, open_stream, password
+ )
+ else:
+ # The backup we're uploading is encrypted, but the agent requires it
+ # decrypted
+ LOGGER.debug(
+ "Uploading decrypted backup %s to agent %s",
+ backup.backup_id,
+ agent_id,
+ )
+ streamer = DecryptedBackupStreamer(
+ self.hass, backup, open_stream, password
+ )
+ if streamer:
+ open_stream_func = streamer.open_stream
+ _backup = replace(
+ backup, protected=should_encrypt, size=streamer.size()
+ )
+ await self.backup_agents[agent_id].async_upload_backup(
+ open_stream=open_stream_func,
+ backup=_backup,
+ )
+ if streamer:
+ await streamer.wait()
+
+ sync_backup_results = await asyncio.gather(
+ *(upload_backup_to_agent(agent_id) for agent_id in agent_ids),
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
+ agent_id = agent_ids[idx]
if isinstance(result, BackupReaderWriterError):
# writer errors will affect all agents
# no point in continuing
raise BackupManagerError(str(result)) from result
if isinstance(result, BackupAgentError):
- LOGGER.error("Error uploading to %s: %s", agent_ids[idx], result)
- agent_errors[agent_ids[idx]] = result
+ agent_errors[agent_id] = result
+ LOGGER.error("Upload failed for %s: %s", agent_id, result)
continue
if isinstance(result, Exception):
# trap bugs from agents
- agent_errors[agent_ids[idx]] = result
- LOGGER.error("Unexpected error: %s", result, exc_info=result)
+ agent_errors[agent_id] = result
+ LOGGER.error(
+ "Unexpected error for %s: %s", agent_id, result, exc_info=result
+ )
continue
if isinstance(result, BaseException):
raise result
@@ -479,7 +575,7 @@ class BackupManager:
agent_backup, await instance_id.async_get(self.hass)
)
backups[backup_id] = ManagerBackup(
- agent_ids=[],
+ agents={},
addons=agent_backup.addons,
backup_id=backup_id,
date=agent_backup.date,
@@ -490,11 +586,12 @@ class BackupManager:
homeassistant_included=agent_backup.homeassistant_included,
homeassistant_version=agent_backup.homeassistant_version,
name=agent_backup.name,
- protected=agent_backup.protected,
- size=agent_backup.size,
with_automatic_settings=with_automatic_settings,
)
- backups[backup_id].agent_ids.append(agent_ids[idx])
+ backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
+ protected=agent_backup.protected,
+ size=agent_backup.size,
+ )
return (backups, agent_errors)
@@ -530,7 +627,7 @@ class BackupManager:
result, await instance_id.async_get(self.hass)
)
backup = ManagerBackup(
- agent_ids=[],
+ agents={},
addons=result.addons,
backup_id=result.backup_id,
date=result.date,
@@ -541,11 +638,12 @@ class BackupManager:
homeassistant_included=result.homeassistant_included,
homeassistant_version=result.homeassistant_version,
name=result.name,
- protected=result.protected,
- size=result.size,
with_automatic_settings=with_automatic_settings,
)
- backup.agent_ids.append(agent_ids[idx])
+ backup.agents[agent_ids[idx]] = AgentBackupStatus(
+ protected=result.protected,
+ size=result.size,
+ )
return (backup, agent_errors)
@@ -589,29 +687,108 @@ class BackupManager:
return agent_errors
+ async def async_delete_filtered_backups(
+ self,
+ *,
+ include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
+ delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
+ ) -> None:
+ """Delete backups parsed with a filter.
+
+ :param include_filter: A filter that should return the backups to consider for
+ deletion. Note: The newest of the backups returned by include_filter will
+ unconditionally be kept, even if delete_filter returns all backups.
+ :param delete_filter: A filter that should return the backups to delete.
+ """
+ backups, get_agent_errors = await self.async_get_backups()
+ if get_agent_errors:
+ LOGGER.debug(
+ "Error getting backups; continuing anyway: %s",
+ get_agent_errors,
+ )
+
+ # Run the include filter first to ensure we only consider backups that
+ # should be included in the deletion process.
+ backups = include_filter(backups)
+
+ LOGGER.debug("Total automatic backups: %s", backups)
+
+ backups_to_delete = delete_filter(backups)
+
+ if not backups_to_delete:
+ return
+
+ # always delete oldest backup first
+ backups_to_delete = dict(
+ sorted(
+ backups_to_delete.items(),
+ key=lambda backup_item: backup_item[1].date,
+ )
+ )
+
+ if len(backups_to_delete) >= len(backups):
+ # Never delete the last backup.
+ last_backup = backups_to_delete.popitem()
+ LOGGER.debug("Keeping the last backup: %s", last_backup)
+
+ LOGGER.debug("Backups to delete: %s", backups_to_delete)
+
+ if not backups_to_delete:
+ return
+
+ backup_ids = list(backups_to_delete)
+ delete_results = await asyncio.gather(
+ *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
+ )
+ agent_errors = {
+ backup_id: error
+ for backup_id, error in zip(backup_ids, delete_results, strict=True)
+ if error
+ }
+ if agent_errors:
+ LOGGER.error(
+ "Error deleting old copies: %s",
+ agent_errors,
+ )
+
async def async_receive_backup(
self,
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
- ) -> None:
+ ) -> str:
"""Receive and store a backup file from upload."""
if self.state is not BackupManagerState.IDLE:
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
- ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS)
+ ReceiveBackupEvent(
+ reason=None,
+ stage=None,
+ state=ReceiveBackupState.IN_PROGRESS,
+ )
)
try:
- await self._async_receive_backup(agent_ids=agent_ids, contents=contents)
+ backup_id = await self._async_receive_backup(
+ agent_ids=agent_ids, contents=contents
+ )
except Exception:
self.async_on_backup_event(
- ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED)
+ ReceiveBackupEvent(
+ reason="unknown_error",
+ stage=None,
+ state=ReceiveBackupState.FAILED,
+ )
)
raise
else:
self.async_on_backup_event(
- ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED)
+ ReceiveBackupEvent(
+ reason=None,
+ stage=None,
+ state=ReceiveBackupState.COMPLETED,
+ )
)
+ return backup_id
finally:
self.async_on_backup_event(IdleEvent())
@@ -620,11 +797,12 @@ class BackupManager:
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
- ) -> None:
+ ) -> str:
"""Receive and store a backup file from upload."""
contents.chunk_size = BUF_SIZE
self.async_on_backup_event(
ReceiveBackupEvent(
+ reason=None,
stage=ReceiveBackupStage.RECEIVE_FILE,
state=ReceiveBackupState.IN_PROGRESS,
)
@@ -636,6 +814,7 @@ class BackupManager:
)
self.async_on_backup_event(
ReceiveBackupEvent(
+ reason=None,
stage=ReceiveBackupStage.UPLOAD_TO_AGENTS,
state=ReceiveBackupState.IN_PROGRESS,
)
@@ -644,14 +823,19 @@ class BackupManager:
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
+ # When receiving a backup, we don't decrypt or encrypt it according to the
+ # agent settings, we just upload it as is.
+ password=None,
)
await written_backup.release_stream()
- self.known_backups.add(written_backup.backup, agent_errors)
+ self.known_backups.add(written_backup.backup, agent_errors, [])
+ return written_backup.backup.backup_id
async def async_create_backup(
self,
*,
agent_ids: list[str],
+ extra_metadata: dict[str, bool | str] | None = None,
include_addons: list[str] | None,
include_all_addons: bool,
include_database: bool,
@@ -664,6 +848,7 @@ class BackupManager:
"""Create a backup."""
new_backup = await self.async_initiate_backup(
agent_ids=agent_ids,
+ extra_metadata=extra_metadata,
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
@@ -678,10 +863,26 @@ class BackupManager:
await self._backup_finish_task
return new_backup
+ async def async_create_automatic_backup(self) -> NewBackup:
+ """Create a backup with automatic backup settings."""
+ config_data = self.config.data
+ return await self.async_create_backup(
+ agent_ids=config_data.create_backup.agent_ids,
+ include_addons=config_data.create_backup.include_addons,
+ include_all_addons=config_data.create_backup.include_all_addons,
+ include_database=config_data.create_backup.include_database,
+ include_folders=config_data.create_backup.include_folders,
+ include_homeassistant=True, # always include HA
+ name=config_data.create_backup.name,
+ password=config_data.create_backup.password,
+ with_automatic_settings=True,
+ )
+
async def async_initiate_backup(
self,
*,
agent_ids: list[str],
+ extra_metadata: dict[str, bool | str] | None = None,
include_addons: list[str] | None,
include_all_addons: bool,
include_database: bool,
@@ -701,11 +902,16 @@ class BackupManager:
self.store.save()
self.async_on_backup_event(
- CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
+ CreateBackupEvent(
+ reason=None,
+ stage=None,
+ state=CreateBackupState.IN_PROGRESS,
+ )
)
try:
return await self._async_create_backup(
agent_ids=agent_ids,
+ extra_metadata=extra_metadata,
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
@@ -716,9 +922,14 @@ class BackupManager:
raise_task_error=raise_task_error,
with_automatic_settings=with_automatic_settings,
)
- except Exception:
+ except Exception as err:
+ reason = err.error_code if isinstance(err, BackupError) else "unknown_error"
self.async_on_backup_event(
- CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
+ CreateBackupEvent(
+ reason=reason,
+ stage=None,
+ state=CreateBackupState.FAILED,
+ )
)
self.async_on_backup_event(IdleEvent())
if with_automatic_settings:
@@ -729,6 +940,7 @@ class BackupManager:
self,
*,
agent_ids: list[str],
+ extra_metadata: dict[str, bool | str] | None,
include_addons: list[str] | None,
include_all_addons: bool,
include_database: bool,
@@ -740,30 +952,43 @@ class BackupManager:
with_automatic_settings: bool,
) -> NewBackup:
"""Initiate generating a backup."""
- if not agent_ids:
- raise BackupManagerError("At least one agent must be selected")
- if invalid_agents := [
+ unavailable_agents = [
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
- ]:
- raise BackupManagerError(f"Invalid agents selected: {invalid_agents}")
+ ]
+ if not (
+ available_agents := [
+ agent_id for agent_id in agent_ids if agent_id in self.backup_agents
+ ]
+ ):
+ raise BackupManagerError(
+ f"At least one available backup agent must be selected, got {agent_ids}"
+ )
+ if unavailable_agents:
+ LOGGER.warning(
+ "Backup agents %s are not available, will backupp to %s",
+ unavailable_agents,
+ available_agents,
+ )
if include_all_addons and include_addons:
raise BackupManagerError(
"Cannot include all addons and specify specific addons"
)
backup_name = (
- name
- or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}"
+ (name if name is None else name.strip())
+ or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
)
+ extra_metadata = extra_metadata or {}
try:
(
new_backup,
self._backup_task,
) = await self._reader_writer.async_create_backup(
- agent_ids=agent_ids,
+ agent_ids=available_agents,
backup_name=backup_name,
- extra_metadata={
+ extra_metadata=extra_metadata
+ | {
"instance_id": await instance_id.async_get(self.hass),
"with_automatic_settings": with_automatic_settings,
},
@@ -779,7 +1004,9 @@ class BackupManager:
raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
- self._async_finish_backup(agent_ids, with_automatic_settings),
+ self._async_finish_backup(
+ available_agents, unavailable_agents, with_automatic_settings, password
+ ),
name="backup_manager_finish_backup",
)
if not raise_task_error:
@@ -796,7 +1023,11 @@ class BackupManager:
return new_backup
async def _async_finish_backup(
- self, agent_ids: list[str], with_automatic_settings: bool
+ self,
+ available_agents: list[str],
+ unavailable_agents: list[str],
+ with_automatic_settings: bool,
+ password: str | None,
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
@@ -815,10 +1046,11 @@ class BackupManager:
LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s",
written_backup.backup.backup_id,
- agent_ids,
+ available_agents,
)
self.async_on_backup_event(
CreateBackupEvent(
+ reason=None,
stage=CreateBackupStage.UPLOAD_TO_AGENTS,
state=CreateBackupState.IN_PROGRESS,
)
@@ -827,12 +1059,15 @@ class BackupManager:
try:
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
- agent_ids=agent_ids,
+ agent_ids=available_agents,
open_stream=written_backup.open_stream,
+ password=password,
)
finally:
await written_backup.release_stream()
- self.known_backups.add(written_backup.backup, agent_errors)
+ self.known_backups.add(
+ written_backup.backup, agent_errors, unavailable_agents
+ )
if not agent_errors:
if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup
@@ -841,7 +1076,7 @@ class BackupManager:
backup_success = True
if with_automatic_settings:
- self._update_issue_after_agent_upload(agent_errors)
+ self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
# delete old backups more numerous than copies
# try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self)
@@ -849,14 +1084,22 @@ class BackupManager:
finally:
self._backup_task = None
self._backup_finish_task = None
- self.async_on_backup_event(
- CreateBackupEvent(
- stage=None,
- state=CreateBackupState.COMPLETED
- if backup_success
- else CreateBackupState.FAILED,
+ if backup_success:
+ self.async_on_backup_event(
+ CreateBackupEvent(
+ reason=None,
+ stage=None,
+ state=CreateBackupState.COMPLETED,
+ )
+ )
+ else:
+ self.async_on_backup_event(
+ CreateBackupEvent(
+ reason="upload_failed",
+ stage=None,
+ state=CreateBackupState.FAILED,
+ )
)
- )
self.async_on_backup_event(IdleEvent())
async def async_restore_backup(
@@ -875,7 +1118,11 @@ class BackupManager:
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
- RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
+ RestoreBackupEvent(
+ reason=None,
+ stage=None,
+ state=RestoreBackupState.IN_PROGRESS,
+ )
)
try:
await self._async_restore_backup(
@@ -888,11 +1135,28 @@ class BackupManager:
restore_homeassistant=restore_homeassistant,
)
self.async_on_backup_event(
- RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED)
+ RestoreBackupEvent(
+ reason=None,
+ stage=None,
+ state=RestoreBackupState.COMPLETED,
+ )
)
+ except BackupError as err:
+ self.async_on_backup_event(
+ RestoreBackupEvent(
+ reason=err.error_code,
+ stage=None,
+ state=RestoreBackupState.FAILED,
+ )
+ )
+ raise
except Exception:
self.async_on_backup_event(
- RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
+ RestoreBackupEvent(
+ reason="unknown_error",
+ stage=None,
+ state=RestoreBackupState.FAILED,
+ )
)
raise
finally:
@@ -923,6 +1187,7 @@ class BackupManager:
backup_id=backup_id,
open_stream=open_backup,
agent_id=agent_id,
+ on_progress=self.async_on_backup_event,
password=password,
restore_addons=restore_addons,
restore_database=restore_database,
@@ -939,6 +1204,8 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
+ if not isinstance(event, IdleEvent):
+ self.last_non_idle_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)
@@ -969,10 +1236,10 @@ class BackupManager:
)
def _update_issue_after_agent_upload(
- self, agent_errors: dict[str, Exception]
+ self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
) -> None:
"""Update issue registry after a backup is uploaded to agents."""
- if not agent_errors:
+ if not agent_errors and not unavailable_agents:
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
return
ir.async_create_issue(
@@ -984,9 +1251,56 @@ class BackupManager:
learn_more_url="homeassistant://config/backup",
severity=ir.IssueSeverity.WARNING,
translation_key="automatic_backup_failed_upload_agents",
- translation_placeholders={"failed_agents": ", ".join(agent_errors)},
+ translation_placeholders={
+ "failed_agents": ", ".join(
+ chain(
+ (
+ self.backup_agents[agent_id].name
+ for agent_id in agent_errors
+ ),
+ unavailable_agents,
+ )
+ )
+ },
)
+ async def async_can_decrypt_on_download(
+ self,
+ backup_id: str,
+ *,
+ agent_id: str,
+ password: str | None,
+ ) -> None:
+ """Check if we are able to decrypt the backup on download."""
+ try:
+ agent = self.backup_agents[agent_id]
+ except KeyError as err:
+ raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err
+ if not await agent.async_get_backup(backup_id):
+ raise BackupManagerError(
+ f"Backup {backup_id} not found in agent {agent_id}"
+ )
+ reader: IO[bytes]
+ if agent_id in self.local_backup_agents:
+ local_agent = self.local_backup_agents[agent_id]
+ path = local_agent.get_backup_path(backup_id)
+ reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
+ else:
+ backup_stream = await agent.async_download_backup(backup_id)
+ reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
+ try:
+ await self.hass.async_add_executor_job(
+ validate_password_stream, reader, password
+ )
+ except backup_util.IncorrectPassword as err:
+ raise IncorrectPasswordError from err
+ except backup_util.UnsupportedSecureTarVersion as err:
+ raise DecryptOnDowloadNotSupported from err
+ except backup_util.DecryptError as err:
+ raise BackupManagerError(str(err)) from err
+ finally:
+ reader.close()
+
class KnownBackups:
"""Track known backups."""
@@ -1014,11 +1328,12 @@ class KnownBackups:
self,
backup: AgentBackup,
agent_errors: dict[str, Exception],
+ unavailable_agents: list[str],
) -> None:
"""Add a backup."""
self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id,
- failed_agent_ids=list(agent_errors),
+ failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
)
self._manager.store.save()
@@ -1077,7 +1392,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
include_database: bool,
include_folders: list[Folder] | None,
include_homeassistant: bool,
- on_progress: Callable[[ManagerStateEvent], None],
+ on_progress: Callable[[CreateBackupEvent], None],
password: str | None,
) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
"""Initiate generating a backup."""
@@ -1117,19 +1432,42 @@ class CoreBackupReaderWriter(BackupReaderWriter):
date_str: str,
extra_metadata: dict[str, bool | str],
include_database: bool,
- on_progress: Callable[[ManagerStateEvent], None],
+ on_progress: Callable[[CreateBackupEvent], None],
password: str | None,
) -> WrittenBackup:
"""Generate a backup."""
manager = self._hass.data[DATA_MANAGER]
+ agent_config = manager.config.data.agents.get(self._local_agent_id)
+ if (
+ self._local_agent_id in agent_ids
+ and agent_config
+ and not agent_config.protected
+ ):
+ password = None
+
+ backup = AgentBackup(
+ addons=[],
+ backup_id=backup_id,
+ database_included=include_database,
+ date=date_str,
+ extra_metadata=extra_metadata,
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version=HAVERSION,
+ name=backup_name,
+ protected=password is not None,
+ size=0,
+ )
+
local_agent_tar_file_path = None
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
- local_agent_tar_file_path = local_agent.get_backup_path(backup_id)
+ local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
on_progress(
CreateBackupEvent(
+ reason=None,
stage=CreateBackupStage.HOME_ASSISTANT,
state=CreateBackupState.IN_PROGRESS,
)
@@ -1167,19 +1505,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
# ValueError from json_bytes
raise BackupReaderWriterError(str(err)) from err
else:
- backup = AgentBackup(
- addons=[],
- backup_id=backup_id,
- database_included=include_database,
- date=date_str,
- extra_metadata=extra_metadata,
- folders=[],
- homeassistant_included=True,
- homeassistant_version=HAVERSION,
- name=backup_name,
- protected=password is not None,
- size=size_in_bytes,
- )
+ backup = replace(backup, size=size_in_bytes)
async_add_executor_job = self._hass.async_add_executor_job
@@ -1231,6 +1557,17 @@ class CoreBackupReaderWriter(BackupReaderWriter):
if not database_included:
excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP
+ def is_excluded_by_filter(path: PurePath) -> bool:
+ """Filter to filter excludes."""
+
+ for exclude in excludes:
+ if not path.match(exclude):
+ continue
+ LOGGER.debug("Ignoring %s because of %s", path, exclude)
+ return True
+
+ return False
+
outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
)
@@ -1249,7 +1586,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
atomic_contents_add(
tar_file=core_tar,
origin_path=Path(self._hass.config.path()),
- excludes=excludes,
+ file_filter=is_excluded_by_filter,
arcname="data",
)
return (tar_file_path, tar_file_path.stat().st_size)
@@ -1282,7 +1619,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER]
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
- tar_file_path = local_agent.get_backup_path(backup.backup_id)
+ tar_file_path = local_agent.get_new_backup_path(backup)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
@@ -1314,6 +1651,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
*,
agent_id: str,
+ on_progress: Callable[[RestoreBackupEvent], None],
password: str | None,
restore_addons: list[str] | None,
restore_database: bool,
@@ -1358,7 +1696,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
validate_password, path, password
)
if not password_valid:
- raise IncorrectPasswordError("The password provided is incorrect.")
+ raise IncorrectPasswordError
def _write_restore_file() -> None:
"""Write the restore file."""
@@ -1376,8 +1714,63 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
await self._hass.async_add_executor_job(_write_restore_file)
+ on_progress(
+ RestoreBackupEvent(
+ reason=None,
+ stage=None,
+ state=RestoreBackupState.CORE_RESTART,
+ )
+ )
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
+ async def async_resume_restore_progress_after_restart(
+ self,
+ *,
+ on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
+ ) -> None:
+ """Check restore status after core restart."""
+
+ def _read_restore_file() -> json_util.JsonObjectType | None:
+ """Read the restore file."""
+ result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE))
+
+ try:
+ restore_result = json_util.json_loads_object(result_path.read_bytes())
+ except FileNotFoundError:
+ return None
+ finally:
+ try:
+ result_path.unlink(missing_ok=True)
+ except OSError as err:
+ LOGGER.warning(
+ "Unexpected error deleting backup restore result file: %s %s",
+ type(err),
+ err,
+ )
+
+ return restore_result
+
+ restore_result = await self._hass.async_add_executor_job(_read_restore_file)
+ if not restore_result:
+ return
+
+ success = restore_result["success"]
+ if not success:
+ LOGGER.warning(
+ "Backup restore failed with %s: %s",
+ restore_result["error_type"],
+ restore_result["error"],
+ )
+ state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED
+ on_progress(
+ RestoreBackupEvent(
+ reason=cast(str, restore_result["error"]),
+ stage=None,
+ state=state,
+ )
+ )
+ on_progress(IdleEvent())
+
def _generate_backup_id(date: str, name: str) -> str:
"""Generate a backup ID."""
diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json
index b399043e013..6cbfb834c7f 100644
--- a/homeassistant/components/backup/manifest.json
+++ b/homeassistant/components/backup/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
- "requirements": ["cronsim==2.6", "securetar==2024.11.0"]
+ "requirements": ["cronsim==2.6", "securetar==2025.1.4"]
}
diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py
index 81c00d699c6..95c5ef9809d 100644
--- a/homeassistant/components/backup/models.py
+++ b/homeassistant/components/backup/models.py
@@ -28,7 +28,7 @@ class Folder(StrEnum):
@dataclass(frozen=True, kw_only=True)
-class AgentBackup:
+class BaseBackup:
"""Base backup class."""
addons: list[AddonInfo]
@@ -40,6 +40,12 @@ class AgentBackup:
homeassistant_included: bool
homeassistant_version: str | None # None if homeassistant_included is False
name: str
+
+
+@dataclass(frozen=True, kw_only=True)
+class AgentBackup(BaseBackup):
+ """Agent backup class."""
+
protected: bool
size: int
@@ -47,12 +53,6 @@ class AgentBackup:
"""Return a dict representation of this backup."""
return asdict(self)
- def as_frontend_json(self) -> dict:
- """Return a dict representation of this backup for sending to frontend."""
- return {
- key: val for key, val in asdict(self).items() if key != "extra_metadata"
- }
-
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Create an instance from a JSON serialization."""
@@ -71,5 +71,31 @@ class AgentBackup:
)
-class BackupManagerError(HomeAssistantError):
+class BackupError(HomeAssistantError):
+ """Base class for backup errors."""
+
+ error_code = "unknown"
+
+
+class BackupAgentError(BackupError):
+ """Base class for backup agent errors."""
+
+ error_code = "backup_agent_error"
+
+
+class BackupManagerError(BackupError):
"""Backup manager error."""
+
+ error_code = "backup_manager_error"
+
+
+class BackupReaderWriterError(BackupError):
+ """Backup reader/writer error."""
+
+ error_code = "backup_reader_writer_error"
+
+
+class BackupNotFound(BackupAgentError, BackupManagerError):
+ """Raised when a backup is not found."""
+
+ error_code = "backup_not_found"
diff --git a/homeassistant/components/backup/services.yaml b/homeassistant/components/backup/services.yaml
index 900aa39dd6e..70900f93bff 100644
--- a/homeassistant/components/backup/services.yaml
+++ b/homeassistant/components/backup/services.yaml
@@ -1 +1,2 @@
create:
+create_automatic:
diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py
index ddabead24f9..9b4af823c77 100644
--- a/homeassistant/components/backup/store.py
+++ b/homeassistant/components/backup/store.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, TypedDict
+from typing import TYPE_CHECKING, Any, TypedDict
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
+STORAGE_VERSION_MINOR = 3
class StoredBackupData(TypedDict):
@@ -25,14 +26,56 @@ class StoredBackupData(TypedDict):
config: StoredBackupConfig
+class _BackupStore(Store[StoredBackupData]):
+ """Class to help storing backup data."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize storage class."""
+ super().__init__(
+ hass,
+ STORAGE_VERSION,
+ STORAGE_KEY,
+ minor_version=STORAGE_VERSION_MINOR,
+ )
+
+ async def _async_migrate_func(
+ self,
+ old_major_version: int,
+ old_minor_version: int,
+ old_data: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Migrate to the new version."""
+ data = old_data
+ if old_major_version == 1:
+ if old_minor_version < 3:
+ # Version 1.2 bumped to 1.3 because 1.2 was changed several
+ # times during development.
+ # Version 1.3 adds per agent settings, configurable backup time
+ # and custom days
+ data["config"]["agents"] = {}
+ data["config"]["schedule"]["time"] = None
+ if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
+ data["config"]["schedule"]["days"] = []
+ data["config"]["schedule"]["recurrence"] = state
+ else:
+ data["config"]["schedule"]["days"] = [state]
+ data["config"]["schedule"]["recurrence"] = "custom_days"
+
+ # Note: We allow reading data with major version 2.
+ # Reject if major version is higher than 2.
+ if old_major_version > 2:
+ raise NotImplementedError
+ return data
+
+
class BackupStore:
"""Store backup config."""
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
- """Initialize the backup manager."""
+ """Initialize the backup store."""
self._hass = hass
self._manager = manager
- self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
+ self._store = _BackupStore(hass)
async def load(self) -> StoredBackupData | None:
"""Load the store."""
diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json
index 43ae57cc781..32d76ded049 100644
--- a/homeassistant/components/backup/strings.json
+++ b/homeassistant/components/backup/strings.json
@@ -13,6 +13,10 @@
"create": {
"name": "Create backup",
"description": "Creates a new backup."
+ },
+ "create_automatic": {
+ "name": "Create automatic backup",
+ "description": "Creates a new backup with automatic backup settings."
}
}
}
diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py
index 930625c52ca..9d8f6e815dc 100644
--- a/homeassistant/components/backup/util.py
+++ b/homeassistant/components/backup/util.py
@@ -3,22 +3,68 @@
from __future__ import annotations
import asyncio
-from pathlib import Path
+from collections.abc import AsyncIterator, Callable, Coroutine
+from concurrent.futures import CancelledError, Future
+import copy
+from dataclasses import dataclass, replace
+from io import BytesIO
+import json
+import os
+from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
-from typing import cast
+import threading
+from typing import IO, Any, Self, cast
import aiohttp
-from securetar import SecureTarFile
+from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER
from .models import AddonInfo, AgentBackup, Folder
+class DecryptError(HomeAssistantError):
+ """Error during decryption."""
+
+ _message = "Unexpected error during decryption."
+
+
+class EncryptError(HomeAssistantError):
+ """Error during encryption."""
+
+ _message = "Unexpected error during encryption."
+
+
+class UnsupportedSecureTarVersion(DecryptError):
+ """Unsupported securetar version."""
+
+ _message = "Unsupported securetar version."
+
+
+class IncorrectPassword(DecryptError):
+ """Invalid password or corrupted backup."""
+
+ _message = "Invalid password or corrupted backup."
+
+
+class BackupEmpty(DecryptError):
+ """No tar files found in the backup."""
+
+ _message = "No tar files found in the backup."
+
+
+class AbortCipher(HomeAssistantError):
+ """Abort the cipher operation."""
+
+ _message = "Abort cipher operation."
+
+
def make_backup_dir(path: Path) -> None:
"""Create a backup directory if it does not exist."""
path.mkdir(exist_ok=True)
@@ -73,6 +119,17 @@ def read_backup(backup_path: Path) -> AgentBackup:
)
+def suggested_filename_from_name_date(name: str, date_str: str) -> str:
+ """Suggest a filename for the backup."""
+ date = dt_util.parse_datetime(date_str, raise_on_error=True)
+ return "_".join(f"{name} {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
+
+
+def suggested_filename(backup: AgentBackup) -> str:
+ """Suggest a filename for the backup."""
+ return suggested_filename_from_name_date(backup.name, backup.date)
+
+
def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
@@ -106,6 +163,396 @@ def validate_password(path: Path, password: str | None) -> bool:
return False
+class AsyncIteratorReader:
+ """Wrap an AsyncIterator."""
+
+ def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
+ """Initialize the wrapper."""
+ self._aborted = False
+ self._hass = hass
+ self._stream = stream
+ self._buffer: bytes | None = None
+ self._next_future: Future[bytes | None] | None = None
+ self._pos: int = 0
+
+ async def _next(self) -> bytes | None:
+ """Get the next chunk from the iterator."""
+ return await anext(self._stream, None)
+
+ def abort(self) -> None:
+ """Abort the reader."""
+ self._aborted = True
+ if self._next_future is not None:
+ self._next_future.cancel()
+
+ def read(self, n: int = -1, /) -> bytes:
+ """Read data from the iterator."""
+ result = bytearray()
+ while n < 0 or len(result) < n:
+ if not self._buffer:
+ self._next_future = asyncio.run_coroutine_threadsafe(
+ self._next(), self._hass.loop
+ )
+ if self._aborted:
+ self._next_future.cancel()
+ raise AbortCipher
+ try:
+ self._buffer = self._next_future.result()
+ except CancelledError as err:
+ raise AbortCipher from err
+ self._pos = 0
+ if not self._buffer:
+ # The stream is exhausted
+ break
+ chunk = self._buffer[self._pos : self._pos + n]
+ result.extend(chunk)
+ n -= len(chunk)
+ self._pos += len(chunk)
+ if self._pos == len(self._buffer):
+ self._buffer = None
+ return bytes(result)
+
+ def close(self) -> None:
+ """Close the iterator."""
+
+
+class AsyncIteratorWriter:
+ """Wrap an AsyncIterator."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the wrapper."""
+ self._aborted = False
+ self._hass = hass
+ self._pos: int = 0
+ self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
+ self._write_future: Future[bytes | None] | None = None
+
+ def __aiter__(self) -> Self:
+ """Return the iterator."""
+ return self
+
+ async def __anext__(self) -> bytes:
+ """Get the next chunk from the iterator."""
+ if data := await self._queue.get():
+ return data
+ raise StopAsyncIteration
+
+ def abort(self) -> None:
+ """Abort the writer."""
+ self._aborted = True
+ if self._write_future is not None:
+ self._write_future.cancel()
+
+ def tell(self) -> int:
+ """Return the current position in the iterator."""
+ return self._pos
+
+ def write(self, s: bytes, /) -> int:
+ """Write data to the iterator."""
+ self._write_future = asyncio.run_coroutine_threadsafe(
+ self._queue.put(s), self._hass.loop
+ )
+ if self._aborted:
+ self._write_future.cancel()
+ raise AbortCipher
+ try:
+ self._write_future.result()
+ except CancelledError as err:
+ raise AbortCipher from err
+ self._pos += len(s)
+ return len(s)
+
+
+def validate_password_stream(
+ input_stream: IO[bytes],
+ password: str | None,
+) -> None:
+ """Decrypt a backup."""
+ with (
+ tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar,
+ ):
+ for obj in input_tar:
+ if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
+ continue
+ istf = SecureTarFile(
+ None, # Not used
+ gzip=False,
+ key=password_to_key(password) if password is not None else None,
+ mode="r",
+ fileobj=input_tar.extractfile(obj),
+ )
+ with istf.decrypt(obj) as decrypted:
+ if istf.securetar_header.plaintext_size is None:
+ raise UnsupportedSecureTarVersion
+ try:
+ decrypted.read(1) # Read a single byte to trigger the decryption
+ except SecureTarReadError as err:
+ raise IncorrectPassword from err
+ return
+ raise BackupEmpty
+
+
+def decrypt_backup(
+ input_stream: IO[bytes],
+ output_stream: IO[bytes],
+ password: str | None,
+ on_done: Callable[[Exception | None], None],
+ minimum_size: int,
+ nonces: list[bytes],
+) -> None:
+ """Decrypt a backup."""
+ error: Exception | None = None
+ try:
+ try:
+ with (
+ tarfile.open(
+ fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
+ ) as input_tar,
+ tarfile.open(
+ fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
+ ) as output_tar,
+ ):
+ _decrypt_backup(input_tar, output_tar, password)
+ except (DecryptError, SecureTarError, tarfile.TarError) as err:
+ LOGGER.warning("Error decrypting backup: %s", err)
+ error = err
+ else:
+ # Pad the output stream to the requested minimum size
+ padding = max(minimum_size - output_stream.tell(), 0)
+ output_stream.write(b"\0" * padding)
+ finally:
+ # Write an empty chunk to signal the end of the stream
+ output_stream.write(b"")
+ except AbortCipher:
+ LOGGER.debug("Cipher operation aborted")
+ finally:
+ on_done(error)
+
+
+def _decrypt_backup(
+ input_tar: tarfile.TarFile,
+ output_tar: tarfile.TarFile,
+ password: str | None,
+) -> None:
+ """Decrypt a backup."""
+ for obj in input_tar:
+ # We compare with PurePath to avoid issues with different path separators,
+ # for example when backup.json is added as "./backup.json"
+ if PurePath(obj.name) == PurePath("backup.json"):
+ # Rewrite the backup.json file to indicate that the backup is decrypted
+ if not (reader := input_tar.extractfile(obj)):
+ raise DecryptError
+ metadata = json_loads_object(reader.read())
+ metadata["protected"] = False
+ updated_metadata_b = json.dumps(metadata).encode()
+ metadata_obj = copy.deepcopy(obj)
+ metadata_obj.size = len(updated_metadata_b)
+ output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
+ continue
+ if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
+ output_tar.addfile(obj, input_tar.extractfile(obj))
+ continue
+ istf = SecureTarFile(
+ None, # Not used
+ gzip=False,
+ key=password_to_key(password) if password is not None else None,
+ mode="r",
+ fileobj=input_tar.extractfile(obj),
+ )
+ with istf.decrypt(obj) as decrypted:
+ if (plaintext_size := istf.securetar_header.plaintext_size) is None:
+ raise UnsupportedSecureTarVersion
+ decrypted_obj = copy.deepcopy(obj)
+ decrypted_obj.size = plaintext_size
+ output_tar.addfile(decrypted_obj, decrypted)
+
+
+def encrypt_backup(
+ input_stream: IO[bytes],
+ output_stream: IO[bytes],
+ password: str | None,
+ on_done: Callable[[Exception | None], None],
+ minimum_size: int,
+ nonces: list[bytes],
+) -> None:
+ """Encrypt a backup."""
+ error: Exception | None = None
+ try:
+ try:
+ with (
+ tarfile.open(
+ fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
+ ) as input_tar,
+ tarfile.open(
+ fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
+ ) as output_tar,
+ ):
+ _encrypt_backup(input_tar, output_tar, password, nonces)
+ except (EncryptError, SecureTarError, tarfile.TarError) as err:
+ LOGGER.warning("Error encrypting backup: %s", err)
+ error = err
+ else:
+ # Pad the output stream to the requested minimum size
+ padding = max(minimum_size - output_stream.tell(), 0)
+ output_stream.write(b"\0" * padding)
+ finally:
+ # Write an empty chunk to signal the end of the stream
+ output_stream.write(b"")
+ except AbortCipher:
+ LOGGER.debug("Cipher operation aborted")
+ finally:
+ on_done(error)
+
+
+def _encrypt_backup(
+ input_tar: tarfile.TarFile,
+ output_tar: tarfile.TarFile,
+ password: str | None,
+ nonces: list[bytes],
+) -> None:
+ """Encrypt a backup."""
+ inner_tar_idx = 0
+ for obj in input_tar:
+ # We compare with PurePath to avoid issues with different path separators,
+ # for example when backup.json is added as "./backup.json"
+ if PurePath(obj.name) == PurePath("backup.json"):
+ # Rewrite the backup.json file to indicate that the backup is encrypted
+ if not (reader := input_tar.extractfile(obj)):
+ raise EncryptError
+ metadata = json_loads_object(reader.read())
+ metadata["protected"] = True
+ updated_metadata_b = json.dumps(metadata).encode()
+ metadata_obj = copy.deepcopy(obj)
+ metadata_obj.size = len(updated_metadata_b)
+ output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
+ continue
+ if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
+ output_tar.addfile(obj, input_tar.extractfile(obj))
+ continue
+ istf = SecureTarFile(
+ None, # Not used
+ gzip=False,
+ key=password_to_key(password) if password is not None else None,
+ mode="r",
+ fileobj=input_tar.extractfile(obj),
+ nonce=nonces[inner_tar_idx],
+ )
+ inner_tar_idx += 1
+ with istf.encrypt(obj) as encrypted:
+ encrypted_obj = copy.deepcopy(obj)
+ encrypted_obj.size = encrypted.encrypted_size
+ output_tar.addfile(encrypted_obj, encrypted)
+
+
+@dataclass(kw_only=True)
+class _CipherWorkerStatus:
+ done: asyncio.Event
+ error: Exception | None = None
+ reader: AsyncIteratorReader
+ thread: threading.Thread
+ writer: AsyncIteratorWriter
+
+
+class _CipherBackupStreamer:
+ """Encrypt or decrypt a backup."""
+
+ _cipher_func: Callable[
+ [
+ IO[bytes],
+ IO[bytes],
+ str | None,
+ Callable[[Exception | None], None],
+ int,
+ list[bytes],
+ ],
+ None,
+ ]
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ backup: AgentBackup,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ password: str | None,
+ ) -> None:
+ """Initialize."""
+ self._workers: list[_CipherWorkerStatus] = []
+ self._backup = backup
+ self._hass = hass
+ self._open_stream = open_stream
+ self._password = password
+ self._nonces: list[bytes] = []
+
+ def size(self) -> int:
+ """Return the maximum size of the decrypted or encrypted backup."""
+ return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
+
+ def _num_tar_files(self) -> int:
+ """Return the number of inner tar files."""
+ b = self._backup
+ return len(b.addons) + len(b.folders) + b.homeassistant_included + 1
+
+ async def open_stream(self) -> AsyncIterator[bytes]:
+ """Open a stream."""
+
+ def on_done(error: Exception | None) -> None:
+ """Call by the worker thread when it's done."""
+ worker_status.error = error
+ self._hass.loop.call_soon_threadsafe(worker_status.done.set)
+
+ stream = await self._open_stream()
+ reader = AsyncIteratorReader(self._hass, stream)
+ writer = AsyncIteratorWriter(self._hass)
+ worker = threading.Thread(
+ target=self._cipher_func,
+ args=[reader, writer, self._password, on_done, self.size(), self._nonces],
+ )
+ worker_status = _CipherWorkerStatus(
+ done=asyncio.Event(), reader=reader, thread=worker, writer=writer
+ )
+ self._workers.append(worker_status)
+ worker.start()
+ return writer
+
+ async def wait(self) -> None:
+ """Wait for the worker threads to finish."""
+ for worker in self._workers:
+ worker.reader.abort()
+ worker.writer.abort()
+ await asyncio.gather(*(worker.done.wait() for worker in self._workers))
+
+
+class DecryptedBackupStreamer(_CipherBackupStreamer):
+ """Decrypt a backup."""
+
+ _cipher_func = staticmethod(decrypt_backup)
+
+ def backup(self) -> AgentBackup:
+ """Return the decrypted backup."""
+ return replace(self._backup, protected=False, size=self.size())
+
+
+class EncryptedBackupStreamer(_CipherBackupStreamer):
+ """Encrypt a backup."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ backup: AgentBackup,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ password: str | None,
+ ) -> None:
+ """Initialize."""
+ super().__init__(hass, backup, open_stream, password)
+ self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())]
+
+ _cipher_func = staticmethod(encrypt_backup)
+
+ def backup(self) -> AgentBackup:
+ """Return the encrypted backup."""
+ return replace(self._backup, protected=True, size=self.size())
+
+
async def receive_file(
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
) -> None:
diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py
index 0139b7fdb77..b6d092e1913 100644
--- a/homeassistant/components/backup/websocket.py
+++ b/homeassistant/components/backup/websocket.py
@@ -6,11 +6,16 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
-from .config import ScheduleState
+from .config import Day, ScheduleRecurrence
from .const import DATA_MANAGER, LOGGER
-from .manager import IncorrectPasswordError, ManagerStateEvent
-from .models import Folder
+from .manager import (
+ DecryptOnDowloadNotSupported,
+ IncorrectPasswordError,
+ ManagerStateEvent,
+)
+from .models import BackupNotFound, Folder
@callback
@@ -24,6 +29,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
websocket_api.async_register_command(hass, handle_details)
websocket_api.async_register_command(hass, handle_info)
+ websocket_api.async_register_command(hass, handle_can_decrypt_on_download)
websocket_api.async_register_command(hass, handle_create)
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
websocket_api.async_register_command(hass, handle_delete)
@@ -51,9 +57,13 @@ async def handle_info(
"agent_errors": {
agent_id: str(err) for agent_id, err in agent_errors.items()
},
- "backups": [backup.as_frontend_json() for backup in backups.values()],
+ "backups": list(backups.values()),
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
+ "last_non_idle_event": manager.last_non_idle_event,
+ "next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
+ "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
+ "state": manager.state,
},
)
@@ -81,7 +91,7 @@ async def handle_details(
"agent_errors": {
agent_id: str(err) for agent_id, err in agent_errors.items()
},
- "backup": backup.as_frontend_json() if backup else None,
+ "backup": backup,
},
)
@@ -141,12 +151,48 @@ async def handle_restore(
restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"],
)
+ except BackupNotFound:
+ connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else:
connection.send_result(msg["id"])
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "backup/can_decrypt_on_download",
+ vol.Required("backup_id"): str,
+ vol.Required("agent_id"): str,
+ vol.Required("password"): str,
+ }
+)
+@websocket_api.async_response
+async def handle_can_decrypt_on_download(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Check if the supplied password is correct."""
+ try:
+ await hass.data[DATA_MANAGER].async_can_decrypt_on_download(
+ msg["backup_id"],
+ agent_id=msg["agent_id"],
+ password=msg.get("password"),
+ )
+ except BackupNotFound:
+ connection.send_error(msg["id"], "backup_not_found", "Backup not found")
+ except IncorrectPasswordError:
+ connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
+ except DecryptOnDowloadNotSupported:
+ connection.send_error(
+ msg["id"], "decrypt_not_supported", "Decrypt on download not supported"
+ )
+ else:
+ connection.send_result(msg["id"])
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -157,8 +203,8 @@ async def handle_restore(
vol.Optional("include_database", default=True): bool,
vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool,
- vol.Optional("name"): str,
- vol.Optional("password"): str,
+ vol.Optional("name"): vol.Any(str, None),
+ vol.Optional("password"): vol.Any(str, None),
}
)
@websocket_api.async_response
@@ -266,7 +312,10 @@ async def backup_agents_info(
connection.send_result(
msg["id"],
{
- "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents],
+ "agents": [
+ {"agent_id": agent.agent_id, "name": agent.name}
+ for agent in manager.backup_agents.values()
+ ],
},
)
@@ -281,10 +330,18 @@ async def handle_config_info(
) -> None:
"""Send the stored backup config."""
manager = hass.data[DATA_MANAGER]
+ config = manager.config.data.to_dict()
+ # Remove state from schedule, it's not needed in the frontend
+ # mypy doesn't like deleting from TypedDict, ignore it
+ del config["schedule"]["state"] # type: ignore[misc]
connection.send_result(
msg["id"],
{
- "config": manager.config.data.to_dict(),
+ "config": config
+ | {
+ "next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
+ "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
+ }
},
)
@@ -293,6 +350,7 @@ async def handle_config_info(
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/config/update",
+ vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
vol.Optional("create_backup"): vol.Schema(
{
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
@@ -314,7 +372,17 @@ async def handle_config_info(
vol.Optional("days"): vol.Any(int, None),
},
),
- vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)),
+ vol.Optional("schedule"): vol.Schema(
+ {
+ vol.Optional("days"): vol.Any(
+ vol.All([vol.Coerce(Day)], vol.Unique()),
+ ),
+ vol.Optional("recurrence"): vol.All(
+ str, vol.Coerce(ScheduleRecurrence)
+ ),
+ vol.Optional("time"): vol.Any(cv.time, None),
+ }
+ ),
}
)
@websocket_api.async_response
diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py
index 0d56699e1ce..4dbb59165fa 100644
--- a/homeassistant/components/baf/config_flow.py
+++ b/homeassistant/components/baf/config_flow.py
@@ -10,9 +10,9 @@ from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, RUN_TIMEOUT
from .models import BAFDiscovery
@@ -44,7 +44,7 @@ class BAFFlowHandler(ConfigFlow, domain=DOMAIN):
self.discovery: BAFDiscovery | None = None
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if discovery_info.ip_address.version == 6:
diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py
index cdb6697d143..064dfb8d24c 100644
--- a/homeassistant/components/baidu/tts.py
+++ b/homeassistant/components/baidu/tts.py
@@ -11,7 +11,7 @@ from homeassistant.components.tts import (
Provider,
)
from homeassistant.const import CONF_API_KEY
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py
index 7838db16820..c982d59d513 100644
--- a/homeassistant/components/balboa/__init__.py
+++ b/homeassistant/components/balboa/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME
diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py
index fccfeceb331..24375ad4e55 100644
--- a/homeassistant/components/balboa/config_flow.py
+++ b/homeassistant/components/balboa/config_flow.py
@@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST
+from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
@@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_SYNC_TIME, DOMAIN
@@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _host: str | None
+ _host: str
+ _model: str
@staticmethod
@callback
@@ -63,6 +65,43 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle DHCP discovery."""
+ await self.async_set_unique_id(format_mac(discovery_info.macaddress))
+ self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
+ self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
+
+ error = None
+ try:
+ info = await validate_input({CONF_HOST: discovery_info.ip})
+ except CannotConnect:
+ error = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ error = "unknown"
+ if not error:
+ self._host = discovery_info.ip
+ self._model = info["title"]
+ self.context["title_placeholders"] = {CONF_MODEL: self._model}
+ return await self.async_step_discovery_confirm()
+ return self.async_abort(reason=error)
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Allow the user to confirm adding the device."""
+ if user_input is not None:
+ data = {CONF_HOST: self._host}
+ return self.async_create_entry(title=self._model, data=data)
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={CONF_HOST: self._host},
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- await self.async_set_unique_id(info["formatted_mac"])
+ await self.async_set_unique_id(
+ info["formatted_mac"], raise_on_progress=False
+ )
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py
index a7d75bfbdf5..a541d044a21 100644
--- a/homeassistant/components/balboa/entity.py
+++ b/homeassistant/components/balboa/entity.py
@@ -20,7 +20,7 @@ class BalboaEntity(Entity):
"""Initialize the control."""
mac = client.mac_address
model = client.model
- self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}'
+ self._attr_unique_id = f"{model}-{key}-{mac.replace(':', '')[-6:]}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
name=model,
diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json
index d7c15bab88f..867e277358c 100644
--- a/homeassistant/components/balboa/manifest.json
+++ b/homeassistant/components/balboa/manifest.json
@@ -3,6 +3,14 @@
"name": "Balboa Spa Client",
"codeowners": ["@garbled1", "@natekspencer"],
"config_flow": true,
+ "dhcp": [
+ {
+ "registered_devices": true
+ },
+ {
+ "macaddress": "001527*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/balboa",
"iot_class": "local_push",
"loggers": ["pybalboa"],
diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json
index 6ced7dfd8c3..c00567a6052 100644
--- a/homeassistant/components/balboa/strings.json
+++ b/homeassistant/components/balboa/strings.json
@@ -1,5 +1,6 @@
{
"config": {
+ "flow_title": "{model}",
"step": {
"user": {
"description": "Connect to the Balboa Wi-Fi device",
@@ -9,6 +10,9 @@
"data_description": {
"host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58."
}
+ },
+ "confirm_discovery": {
+ "description": "Do you want to set up the spa at {host}?"
}
},
"error": {
diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py
index be99f8b5b7d..eab2bb3d4e5 100644
--- a/homeassistant/components/bang_olufsen/__init__.py
+++ b/homeassistant/components/bang_olufsen/__init__.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN
@@ -34,7 +34,7 @@ class BangOlufsenData:
type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
-PLATFORMS = [Platform.MEDIA_PLAYER]
+PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py
index e1c1c7ab538..e776b63b945 100644
--- a/homeassistant/components/bang_olufsen/config_flow.py
+++ b/homeassistant/components/bang_olufsen/config_flow.py
@@ -10,10 +10,10 @@ from mozart_api.exceptions import ApiException
from mozart_api.mozart_client import MozartClient
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import get_default_context
from .const import (
diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py
index 9f0649e610b..c5ee5d1a26e 100644
--- a/homeassistant/components/bang_olufsen/const.py
+++ b/homeassistant/components/bang_olufsen/const.py
@@ -79,6 +79,7 @@ class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
+ BUTTON = "button"
PLAYBACK_ERROR = "playback_error"
PLAYBACK_METADATA = "playback_metadata"
PLAYBACK_PROGRESS = "playback_progress"
@@ -203,14 +204,60 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
),
]
)
+# Map for storing compatibility of devices.
+MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons"
+
+MODEL_SUPPORT_MAP = {
+ MODEL_SUPPORT_DEVICE_BUTTONS: (
+ BangOlufsenModel.BEOLAB_8,
+ BangOlufsenModel.BEOLAB_28,
+ BangOlufsenModel.BEOSOUND_2,
+ BangOlufsenModel.BEOSOUND_A5,
+ BangOlufsenModel.BEOSOUND_A9,
+ BangOlufsenModel.BEOSOUND_BALANCE,
+ BangOlufsenModel.BEOSOUND_EMERGE,
+ BangOlufsenModel.BEOSOUND_LEVEL,
+ BangOlufsenModel.BEOSOUND_THEATRE,
+ )
+}
# Device events
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
+# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
+EVENT_TRANSLATION_MAP: dict[str, str] = {
+ "shortPress (Release)": "short_press_release",
+ "longPress (Timeout)": "long_press_timeout",
+ "longPress (Release)": "long_press_release",
+ "veryLongPress (Timeout)": "very_long_press_timeout",
+ "veryLongPress (Release)": "very_long_press_release",
+}
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
+DEVICE_BUTTONS: Final[list[str]] = [
+ "Bluetooth",
+ "Microphone",
+ "Next",
+ "PlayPause",
+ "Preset1",
+ "Preset2",
+ "Preset3",
+ "Preset4",
+ "Previous",
+ "Volume",
+]
+
+
+DEVICE_BUTTON_EVENTS: Final[list[str]] = [
+ "short_press_release",
+ "long_press_timeout",
+ "long_press_release",
+ "very_long_press_timeout",
+ "very_long_press_release",
+]
+
# Beolink Converter NL/ML sources need to be transformed to upper case
BEOLINK_JOIN_SOURCES_TO_UPPER = (
"aux_a",
diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py
index cab7eae5e25..bf7b06e694a 100644
--- a/homeassistant/components/bang_olufsen/diagnostics.py
+++ b/homeassistant/components/bang_olufsen/diagnostics.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
from .const import DOMAIN
diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py
new file mode 100644
index 00000000000..80ad4060c5e
--- /dev/null
+++ b/homeassistant/components/bang_olufsen/event.py
@@ -0,0 +1,76 @@
+"""Event entities for the Bang & Olufsen integration."""
+
+from __future__ import annotations
+
+from homeassistant.components.event import EventDeviceClass, EventEntity
+from homeassistant.const import CONF_MODEL
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import BangOlufsenConfigEntry
+from .const import (
+ CONNECTION_STATUS,
+ DEVICE_BUTTON_EVENTS,
+ DEVICE_BUTTONS,
+ MODEL_SUPPORT_DEVICE_BUTTONS,
+ MODEL_SUPPORT_MAP,
+ WebsocketNotification,
+)
+from .entity import BangOlufsenEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: BangOlufsenConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Sensor entities from config entry."""
+
+ if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]:
+ async_add_entities(
+ BangOlufsenButtonEvent(config_entry, button_type)
+ for button_type in DEVICE_BUTTONS
+ )
+
+
+class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
+ """Event class for Button events."""
+
+ _attr_device_class = EventDeviceClass.BUTTON
+ _attr_entity_registry_enabled_default = False
+ _attr_event_types = DEVICE_BUTTON_EVENTS
+
+ def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
+ """Initialize Button."""
+ super().__init__(config_entry, config_entry.runtime_data.client)
+
+ self._attr_unique_id = f"{self._unique_id}_{button_type}"
+
+ # Make the native button name Home Assistant compatible
+ self._attr_translation_key = button_type.lower()
+
+ self._button_type = button_type
+
+ async def async_added_to_hass(self) -> None:
+ """Listen to WebSocket button events."""
+ 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.BUTTON}_{self._button_type}",
+ self._async_handle_event,
+ )
+ )
+
+ @callback
+ def _async_handle_event(self, event: str) -> None:
+ """Handle event."""
+ self._trigger_event(event)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json
index b4aac78756c..57ab828f9fb 100644
--- a/homeassistant/components/bang_olufsen/strings.json
+++ b/homeassistant/components/bang_olufsen/strings.json
@@ -1,7 +1,12 @@
{
"common": {
+ "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
"jid_options_name": "JID options",
- "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity."
+ "long_press_release": "Release of long press",
+ "long_press_timeout": "Long press",
+ "short_press_release": "Release of short press",
+ "very_long_press_release": "Release of very long press",
+ "very_long_press_timeout": "Very long press"
},
"config": {
"error": {
@@ -29,6 +34,150 @@
}
}
},
+ "entity": {
+ "event": {
+ "bluetooth": {
+ "name": "Bluetooth",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "microphone": {
+ "name": "Microphone",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "next": {
+ "name": "Next",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "playpause": {
+ "name": "Play / Pause",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "preset1": {
+ "name": "Favourite 1",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "preset2": {
+ "name": "Favourite 2",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "preset3": {
+ "name": "Favourite 3",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "preset4": {
+ "name": "Favourite 4",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "previous": {
+ "name": "Previous",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ },
+ "volume": {
+ "name": "Volume",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
+ "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
+ "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
+ "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
+ "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
+ }
+ }
+ }
+ }
+ }
+ },
"selector": {
"source_ids": {
"options": {
diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py
index bc817226b61..a6ae0358842 100644
--- a/homeassistant/components/bang_olufsen/websocket.py
+++ b/homeassistant/components/bang_olufsen/websocket.py
@@ -3,8 +3,10 @@
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
from mozart_api.models import (
+ ButtonEvent,
ListeningModeProps,
PlaybackContentMetadata,
PlaybackError,
@@ -26,6 +28,7 @@ from homeassistant.util.enum import try_parse_enum
from .const import (
BANG_OLUFSEN_WEBSOCKET_EVENT,
CONNECTION_STATUS,
+ EVENT_TRANSLATION_MAP,
WebsocketNotification,
)
from .entity import BangOlufsenBase
@@ -54,6 +57,8 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self._client.get_active_listening_mode_notifications(
self.on_active_listening_mode
)
+ self._client.get_button_notifications(self.on_button_notification)
+
self._client.get_playback_error_notifications(
self.on_playback_error_notification
)
@@ -104,6 +109,19 @@ class BangOlufsenWebsocket(BangOlufsenBase):
notification,
)
+ def on_button_notification(self, notification: ButtonEvent) -> None:
+ """Send button dispatch."""
+ # State is expected to always be available.
+ if TYPE_CHECKING:
+ assert notification.state
+
+ # Send to event entity
+ async_dispatcher_send(
+ self.hass,
+ f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
+ EVENT_TRANSLATION_MAP[notification.state],
+ )
+
def on_notification_notification(
self, notification: WebsocketNotificationTag
) -> None:
diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py
index 6d203c344f2..32f43983991 100644
--- a/homeassistant/components/bayesian/binary_sensor.py
+++ b/homeassistant/components/bayesian/binary_sensor.py
@@ -31,8 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import ConditionError, TemplateError
-from homeassistant.helpers import condition
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import (
TrackTemplate,
@@ -131,7 +130,10 @@ def _no_overlapping(configs: list[dict]) -> list[dict]:
for i, tup in enumerate(intervals):
if len(intervals) > i + 1 and tup.below > intervals[i + 1].above:
raise vol.Invalid(
- f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}."
+ "Ranges for bayesian numeric state entities must not overlap, "
+ f"but {ent_id} has overlapping ranges, above:{tup.above}, "
+ f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, "
+ f"below:{intervals[i + 1].below}."
)
return configs
@@ -206,7 +208,10 @@ async def async_setup_platform(
broken_observations: list[dict[str, Any]] = []
for observation in observations:
if CONF_P_GIVEN_F not in observation:
- text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}"
+ text = (
+ f"{name}/{observation.get(CONF_ENTITY_ID, '')}"
+ f"{observation.get(CONF_VALUE_TEMPLATE, '')}"
+ )
raise_no_prob_given_false(hass, text)
_LOGGER.error("Missing prob_given_false YAML entry for %s", text)
broken_observations.append(observation)
diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py
index 12174d395f7..18b62f2a506 100644
--- a/homeassistant/components/bbox/device_tracker.py
+++ b/homeassistant/components/bbox/device_tracker.py
@@ -16,10 +16,9 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-from homeassistant.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py
index 72fa870efbf..fed059247d0 100644
--- a/homeassistant/components/bbox/sensor.py
+++ b/homeassistant/components/bbox/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py
index 1c80f62e64f..3a0a6f21f98 100644
--- a/homeassistant/components/beewi_smartclim/sensor.py
+++ b/homeassistant/components/beewi_smartclim/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py
index f31c3d102b0..7b0c121ac6b 100644
--- a/homeassistant/components/binary_sensor/__init__.py
+++ b/homeassistant/components/binary_sensor/__init__.py
@@ -7,7 +7,7 @@ from enum import StrEnum
import logging
from typing import Literal, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py
index e4da2ddc2f4..cb7bc5a043b 100644
--- a/homeassistant/components/bitcoin/sensor.py
+++ b/homeassistant/components/bitcoin/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py
index 3efddf0b0d7..085c0093073 100644
--- a/homeassistant/components/bizkaibus/sensor.py
+++ b/homeassistant/components/bizkaibus/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py
index 37672e98e0b..2d39512cbe0 100644
--- a/homeassistant/components/blackbird/media_player.py
+++ b/homeassistant/components/blackbird/media_player.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
CONF_TYPE,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py
index 2221e35a81f..523b5af793f 100644
--- a/homeassistant/components/blebox/config_flow.py
+++ b/homeassistant/components/blebox/config_flow.py
@@ -15,10 +15,10 @@ from blebox_uniapi.error import (
from blebox_uniapi.session import ApiHost
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
from .const import (
@@ -84,7 +84,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
hass = self.hass
diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py
index 56a84135a9b..e35dd20eea7 100644
--- a/homeassistant/components/blink/camera.py
+++ b/homeassistant/components/blink/camera.py
@@ -13,8 +13,7 @@ from homeassistant.components.camera import Camera
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py
index 19ac5f80242..01e5c90aadf 100644
--- a/homeassistant/components/blinksticklight/light.py
+++ b/homeassistant/components/blinksticklight/light.py
@@ -17,10 +17,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
CONF_SERIAL = "serial"
diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py
index 8ae091fa95e..a6aedb2c472 100644
--- a/homeassistant/components/blockchain/sensor.py
+++ b/homeassistant/components/blockchain/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json
index 3ba6349b714..0154c794c33 100644
--- a/homeassistant/components/blue_current/strings.json
+++ b/homeassistant/components/blue_current/strings.json
@@ -5,7 +5,7 @@
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
- "description": "Enter your Blue Current api token",
+ "description": "Enter your Blue Current API token",
"title": "Authentication"
}
},
@@ -19,7 +19,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "wrong_account": "Wrong account: Please authenticate with the api key for {email}."
+ "wrong_account": "Wrong account: Please authenticate with the API token for {email}."
}
},
"entity": {
diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py
index c10da532324..8582761bafb 100644
--- a/homeassistant/components/blueprint/importer.py
+++ b/homeassistant/components/blueprint/importer.py
@@ -13,7 +13,7 @@ import yarl
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client, config_validation as cv
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
from .models import Blueprint
from .schemas import BLUEPRINT_SCHEMA, is_blueprint_config
@@ -115,7 +115,7 @@ def _extract_blueprint_from_community_topic(
block_content = html.unescape(block_content.strip())
try:
- data = yaml.parse_yaml(block_content)
+ data = yaml_util.parse_yaml(block_content)
except HomeAssistantError:
if block_syntax == "yaml":
raise
@@ -136,7 +136,7 @@ def _extract_blueprint_from_community_topic(
)
return ImportedBlueprint(
- f'{post["username"]}/{topic["slug"]}', block_content, blueprint
+ f"{post['username']}/{topic['slug']}", block_content, blueprint
)
@@ -167,14 +167,13 @@ async def fetch_blueprint_from_github_url(
resp = await session.get(import_url, raise_for_status=True)
raw_yaml = await resp.text()
- data = yaml.parse_yaml(raw_yaml)
+ data = yaml_util.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
parsed_import_url = yarl.URL(import_url)
suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}"
- if suggested_filename.endswith(".yaml"):
- suggested_filename = suggested_filename[:-5]
+ suggested_filename = suggested_filename.removesuffix(".yaml")
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
@@ -205,7 +204,7 @@ async def fetch_blueprint_from_github_gist_url(
continue
content = info["content"]
- data = yaml.parse_yaml(content)
+ data = yaml_util.parse_yaml(content)
if not is_blueprint_config(data):
continue
@@ -236,7 +235,7 @@ async def fetch_blueprint_from_website_url(
resp = await session.get(url, raise_for_status=True)
raw_yaml = await resp.text()
- data = yaml.parse_yaml(raw_yaml)
+ data = yaml_util.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
@@ -253,7 +252,7 @@ async def fetch_blueprint_from_generic_url(
resp = await session.get(url, raise_for_status=True)
raw_yaml = await resp.text()
- data = yaml.parse_yaml(raw_yaml)
+ data = yaml_util.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py
index f32c3f04989..88052100259 100644
--- a/homeassistant/components/blueprint/models.py
+++ b/homeassistant/components/blueprint/models.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
from .const import (
BLUEPRINT_FOLDER,
@@ -79,7 +79,7 @@ class Blueprint:
self.domain = data_domain
- missing = yaml.extract_inputs(data) - set(self.inputs)
+ missing = yaml_util.extract_inputs(data) - set(self.inputs)
if missing:
raise InvalidBlueprint(
@@ -117,7 +117,7 @@ class Blueprint:
def yaml(self) -> str:
"""Dump blueprint as YAML."""
- return yaml.dump(self.data)
+ return yaml_util.dump(self.data)
@callback
def validate(self) -> list[str] | None:
@@ -179,7 +179,7 @@ class BlueprintInputs:
@callback
def async_substitute(self) -> dict:
"""Get the blueprint value with the inputs substituted."""
- processed = yaml.substitute(self.blueprint.data, self.inputs_with_default)
+ processed = yaml_util.substitute(self.blueprint.data, self.inputs_with_default)
combined = {**processed, **self.config_with_inputs}
# From config_with_inputs
combined.pop(CONF_USE_BLUEPRINT)
@@ -225,7 +225,9 @@ class DomainBlueprints:
def _load_blueprint(self, blueprint_path: str) -> Blueprint:
"""Load a blueprint."""
try:
- blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path)
+ blueprint_data = yaml_util.load_yaml_dict(
+ self.blueprint_folder / blueprint_path
+ )
except FileNotFoundError as err:
raise FailedToLoad(
self.domain,
diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py
index 3be925c7c8f..0743d027d8d 100644
--- a/homeassistant/components/blueprint/websocket_api.py
+++ b/homeassistant/components/blueprint/websocket_api.py
@@ -13,7 +13,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
from . import importer, models
from .const import DOMAIN
@@ -174,7 +174,7 @@ async def ws_save_blueprint(
domain = msg["domain"]
try:
- yaml_data = cast(dict[str, Any], yaml.parse_yaml(msg["yaml"]))
+ yaml_data = cast(dict[str, Any], yaml_util.parse_yaml(msg["yaml"]))
blueprint = models.Blueprint(
yaml_data, expected_domain=domain, schema=BLUEPRINT_SCHEMA
)
@@ -263,7 +263,7 @@ async def ws_substitute_blueprint(
try:
config = blueprint_inputs.async_substitute()
- except yaml.UndefinedSubstitution as err:
+ except yaml_util.UndefinedSubstitution as err:
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
return
diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py
index b3facc0b8ac..6cf1957f799 100644
--- a/homeassistant/components/bluesound/__init__.py
+++ b/homeassistant/components/bluesound/__init__.py
@@ -14,10 +14,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
+from .coordinator import BluesoundCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-PLATFORMS = [Platform.MEDIA_PLAYER]
+PLATFORMS = [
+ Platform.MEDIA_PLAYER,
+]
@dataclass
@@ -26,6 +29,7 @@ class BluesoundRuntimeData:
player: Player
sync_status: SyncStatus
+ coordinator: BluesoundCoordinator
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
@@ -33,9 +37,6 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
- if DOMAIN not in hass.data:
- hass.data[DOMAIN] = []
-
return True
@@ -46,13 +47,16 @@ async def async_setup_entry(
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
session = async_get_clientsession(hass)
- async with Player(host, port, session=session, default_timeout=10) as player:
- try:
- sync_status = await player.sync_status(timeout=1)
- except PlayerUnreachableError as ex:
- raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
+ player = Player(host, port, session=session, default_timeout=10)
+ try:
+ sync_status = await player.sync_status(timeout=1)
+ except PlayerUnreachableError as ex:
+ raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
- config_entry.runtime_data = BluesoundRuntimeData(player, sync_status)
+ coordinator = BluesoundCoordinator(hass, player, sync_status)
+ await coordinator.async_config_entry_first_refresh()
+
+ config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py
index 050b3ee4eac..2f002b70e1d 100644
--- a/homeassistant/components/bluesound/config_flow.py
+++ b/homeassistant/components/bluesound/config_flow.py
@@ -7,10 +7,10 @@ from pyblu import Player, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .media_player import DEFAULT_PORT
@@ -71,29 +71,8 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import bluesound config entry from configuration.yaml."""
- session = async_get_clientsession(self.hass)
- async with Player(
- import_data[CONF_HOST], import_data[CONF_PORT], session=session
- ) as player:
- try:
- sync_status = await player.sync_status(timeout=1)
- except PlayerUnreachableError:
- return self.async_abort(reason="cannot_connect")
-
- await self.async_set_unique_id(
- format_unique_id(sync_status.mac, import_data[CONF_PORT])
- )
- self._abort_if_unique_id_configured()
-
- return self.async_create_entry(
- title=sync_status.name,
- data=import_data,
- )
-
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
if discovery_info.port is not None:
diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py
new file mode 100644
index 00000000000..e62f3ef96cf
--- /dev/null
+++ b/homeassistant/components/bluesound/coordinator.py
@@ -0,0 +1,160 @@
+"""Define a base coordinator for Bluesound entities."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable, Coroutine
+import contextlib
+from dataclasses import dataclass, replace
+from datetime import timedelta
+import logging
+
+from pyblu import Input, Player, Preset, Status, SyncStatus
+from pyblu.errors import PlayerUnreachableError
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3)
+PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15)
+
+
+@dataclass
+class BluesoundData:
+ """Define a class to hold Bluesound data."""
+
+ sync_status: SyncStatus
+ status: Status
+ presets: list[Preset]
+ inputs: list[Input]
+
+
+def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]:
+ """Cancel a task."""
+
+ async def _cancel_task() -> None:
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+
+ return _cancel_task
+
+
+class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
+ """Define an object to hold Bluesound data."""
+
+ def __init__(
+ self, hass: HomeAssistant, player: Player, sync_status: SyncStatus
+ ) -> None:
+ """Initialize."""
+ self.player = player
+ self._inital_sync_status = sync_status
+
+ super().__init__(
+ hass,
+ logger=_LOGGER,
+ name=sync_status.name,
+ )
+
+ async def _async_setup(self) -> None:
+ assert self.config_entry is not None
+
+ preset = await self.player.presets()
+ inputs = await self.player.inputs()
+ status = await self.player.status()
+
+ self.async_set_updated_data(
+ BluesoundData(
+ sync_status=self._inital_sync_status,
+ status=status,
+ presets=preset,
+ inputs=inputs,
+ )
+ )
+
+ status_loop_task = self.hass.async_create_background_task(
+ self._poll_status_loop(),
+ name=f"bluesound.poll_status_loop_{self.data.sync_status.id}",
+ )
+ self.config_entry.async_on_unload(cancel_task(status_loop_task))
+
+ sync_status_loop_task = self.hass.async_create_background_task(
+ self._poll_sync_status_loop(),
+ name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}",
+ )
+ self.config_entry.async_on_unload(cancel_task(sync_status_loop_task))
+
+ presets_and_inputs_loop_task = self.hass.async_create_background_task(
+ self._poll_presets_and_inputs_loop(),
+ name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}",
+ )
+ self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task))
+
+ async def _async_update_data(self) -> BluesoundData:
+ return self.data
+
+ async def _poll_presets_and_inputs_loop(self) -> None:
+ while True:
+ await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds())
+ try:
+ preset = await self.player.presets()
+ inputs = await self.player.inputs()
+ self.async_set_updated_data(
+ replace(
+ self.data,
+ presets=preset,
+ inputs=inputs,
+ )
+ )
+ except PlayerUnreachableError as ex:
+ self.async_set_update_error(ex)
+ except asyncio.CancelledError:
+ return
+ except Exception as ex: # noqa: BLE001 - this loop should never stop
+ self.async_set_update_error(ex)
+
+ async def _poll_status_loop(self) -> None:
+ """Loop which polls the status of the player."""
+ while True:
+ try:
+ status = await self.player.status(
+ etag=self.data.status.etag, poll_timeout=120, timeout=125
+ )
+ self.async_set_updated_data(
+ replace(
+ self.data,
+ status=status,
+ )
+ )
+ except PlayerUnreachableError as ex:
+ self.async_set_update_error(ex)
+ await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
+ except asyncio.CancelledError:
+ return
+ except Exception as ex: # noqa: BLE001 - this loop should never stop
+ self.async_set_update_error(ex)
+ await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
+
+ async def _poll_sync_status_loop(self) -> None:
+ """Loop which polls the sync status of the player."""
+ while True:
+ try:
+ sync_status = await self.player.sync_status(
+ etag=self.data.sync_status.etag, poll_timeout=120, timeout=125
+ )
+ self.async_set_updated_data(
+ replace(
+ self.data,
+ sync_status=sync_status,
+ )
+ )
+ except PlayerUnreachableError as ex:
+ self.async_set_update_error(ex)
+ await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
+ except asyncio.CancelledError:
+ raise
+ except Exception as ex: # noqa: BLE001 - this loop should never stop
+ self.async_set_update_error(ex)
+ await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index 4882d543617..6bb3c101cd1 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -2,20 +2,16 @@
from __future__ import annotations
-import asyncio
-from asyncio import CancelledError, Task
-from contextlib import suppress
+from asyncio import Task
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
-from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
- PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
@@ -23,16 +19,10 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
-from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import (
- config_validation as cv,
- entity_platform,
- issue_registry as ir,
-)
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -43,10 +33,11 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.util import dt as dt_util
-from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE
+from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
+from .coordinator import BluesoundCoordinator
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
@@ -64,71 +55,8 @@ SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
-NODE_OFFLINE_CHECK_TIMEOUT = 180
-NODE_RETRY_INITIATION = timedelta(minutes=3)
-
-SYNC_STATUS_INTERVAL = timedelta(minutes=5)
-
POLL_TIMEOUT = 120
-PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_HOSTS): vol.All(
- cv.ensure_list,
- [
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- }
- ],
- )
- }
-)
-
-
-async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
- """Import config entry from configuration.yaml."""
- if not hass.config_entries.async_entries(DOMAIN):
- # Start import flow
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config
- )
- if (
- result["type"] == FlowResultType.ABORT
- and result["reason"] == "cannot_connect"
- ):
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{result['reason']}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=ir.IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": INTEGRATION_TITLE,
- },
- )
- return
-
- ir.async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=ir.IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": INTEGRATION_TITLE,
- },
- )
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -137,10 +65,10 @@ async def async_setup_entry(
) -> None:
"""Set up the Bluesound entry."""
bluesound_player = BluesoundPlayer(
+ config_entry.runtime_data.coordinator,
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.runtime_data.player,
- config_entry.runtime_data.sync_status,
)
platform = entity_platform.async_get_current_platform()
@@ -155,27 +83,10 @@ async def async_setup_entry(
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
- hass.data[DATA_BLUESOUND].append(bluesound_player)
async_add_entities([bluesound_player], update_before_add=True)
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None,
-) -> None:
- """Trigger import flows."""
- hosts = config.get(CONF_HOSTS, [])
- for host in hosts:
- import_data = {
- CONF_HOST: host[CONF_HOST],
- CONF_PORT: host.get(CONF_PORT, 11000),
- }
- hass.async_create_task(_async_import(hass, import_data))
-
-
-class BluesoundPlayer(MediaPlayerEntity):
+class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity):
"""Representation of a Bluesound Player."""
_attr_media_content_type = MediaType.MUSIC
@@ -184,12 +95,15 @@ class BluesoundPlayer(MediaPlayerEntity):
def __init__(
self,
+ coordinator: BluesoundCoordinator,
host: str,
port: int,
player: Player,
- sync_status: SyncStatus,
) -> None:
"""Initialize the media player."""
+ super().__init__(coordinator)
+ sync_status = coordinator.data.sync_status
+
self.host = host
self.port = port
self._poll_status_loop_task: Task[None] | None = None
@@ -197,15 +111,14 @@ class BluesoundPlayer(MediaPlayerEntity):
self._id = sync_status.id
self._last_status_update: datetime | None = None
self._sync_status = sync_status
- self._status: Status | None = None
- self._inputs: list[Input] = []
- self._presets: list[Preset] = []
+ self._status: Status = coordinator.data.status
+ self._inputs: list[Input] = coordinator.data.inputs
+ self._presets: list[Preset] = coordinator.data.presets
self._group_name: str | None = None
self._group_list: list[str] = []
self._bluesound_device_name = sync_status.name
self._player = player
- self._is_leader = False
- self._leader: BluesoundPlayer | None = None
+ self._last_status_update = dt_util.utcnow()
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
@@ -228,52 +141,10 @@ class BluesoundPlayer(MediaPlayerEntity):
via_device=(DOMAIN, format_mac(sync_status.mac)),
)
- async def _poll_status_loop(self) -> None:
- """Loop which polls the status of the player."""
- while True:
- try:
- await self.async_update_status()
- except PlayerUnreachableError:
- _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: # noqa: E722 - this loop should never stop
- _LOGGER.exception(
- "Unexpected error for %s:%s, retrying later", self.host, self.port
- )
- await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
-
- async def _poll_sync_status_loop(self) -> None:
- """Loop which polls the sync status of the player."""
- while True:
- try:
- await self.update_sync_status()
- except PlayerUnreachableError:
- await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
- except CancelledError:
- raise
- except: # noqa: E722 - all errors must be caught for this loop
- await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
-
async def async_added_to_hass(self) -> None:
"""Start the polling task."""
await super().async_added_to_hass()
- self._poll_status_loop_task = self.hass.async_create_background_task(
- self._poll_status_loop(),
- name=f"bluesound.poll_status_loop_{self.host}:{self.port}",
- )
- self._poll_sync_status_loop_task = self.hass.async_create_background_task(
- self._poll_sync_status_loop(),
- name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}",
- )
-
assert self._sync_status.id is not None
self.async_on_remove(
async_dispatcher_connect(
@@ -294,105 +165,24 @@ class BluesoundPlayer(MediaPlayerEntity):
"""Stop the polling task."""
await super().async_will_remove_from_hass()
- assert self._poll_status_loop_task is not None
- if self._poll_status_loop_task.cancel():
- # the sleeps in _poll_loop will raise CancelledError
- with suppress(CancelledError):
- await self._poll_status_loop_task
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._sync_status = self.coordinator.data.sync_status
+ self._status = self.coordinator.data.status
+ self._inputs = self.coordinator.data.inputs
+ self._presets = self.coordinator.data.presets
- assert self._poll_sync_status_loop_task is not None
- if self._poll_sync_status_loop_task.cancel():
- # the sleeps in _poll_sync_status_loop will raise CancelledError
- with suppress(CancelledError):
- await self._poll_sync_status_loop_task
-
- self.hass.data[DATA_BLUESOUND].remove(self)
-
- async def async_update(self) -> None:
- """Update internal status of the entity."""
- if not self.available:
- return
-
- with suppress(PlayerUnreachableError):
- await self.async_update_presets()
- await self.async_update_captures()
-
- async def async_update_status(self) -> None:
- """Use the poll session to always get the status of the player."""
- etag = None
- if self._status is not None:
- etag = self._status.etag
-
- try:
- status = await self._player.status(
- etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
- )
-
- self._attr_available = True
- self._last_status_update = dt_util.utcnow()
- self._status = status
-
- self.async_write_ha_state()
- except PlayerUnreachableError:
- self._attr_available = False
- self._last_status_update = None
- self._status = None
- self.async_write_ha_state()
- _LOGGER.error(
- "Client connection error, marking %s as offline",
- self._bluesound_device_name,
- )
- raise
-
- async def update_sync_status(self) -> None:
- """Update the internal status."""
- etag = None
- if self._sync_status:
- etag = self._sync_status.etag
- sync_status = await self._player.sync_status(
- etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
- )
-
- self._sync_status = sync_status
+ self._last_status_update = dt_util.utcnow()
self._group_list = self.rebuild_bluesound_group()
- if sync_status.leader is not None:
- self._is_leader = False
- leader_id = f"{sync_status.leader.ip}:{sync_status.leader.port}"
- leader_device = [
- device
- for device in self.hass.data[DATA_BLUESOUND]
- if device.id == leader_id
- ]
-
- if leader_device and leader_id != self.id:
- self._leader = leader_device[0]
- else:
- self._leader = None
- _LOGGER.error("Leader not found %s", leader_id)
- else:
- if self._leader is not None:
- self._leader = None
- followers = self._sync_status.followers
- self._is_leader = followers is not None
-
self.async_write_ha_state()
- async def async_update_captures(self) -> None:
- """Update Capture sources."""
- inputs = await self._player.inputs()
- self._inputs = inputs
-
- async def async_update_presets(self) -> None:
- """Update Presets."""
- presets = await self._player.presets()
- self._presets = presets
-
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
- if self._status is None:
+ if self.available is False:
return MediaPlayerState.OFF
if self.is_grouped and not self.is_leader:
@@ -409,7 +199,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
return self._status.name
@@ -417,7 +207,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_artist(self) -> str | None:
"""Artist of current playing media (Music track only)."""
- if self._status is None:
+ if self.available is False:
return None
if self.is_grouped and not self.is_leader:
@@ -428,7 +218,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_album_name(self) -> str | None:
"""Artist of current playing media (Music track only)."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
return self._status.album
@@ -436,7 +226,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
url = self._status.image
@@ -451,7 +241,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
mediastate = self.state
@@ -470,7 +260,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
duration = self._status.total_seconds
@@ -487,16 +277,11 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
- volume = None
+ volume = self._status.volume
- if self._status is not None:
- volume = self._status.volume
if self.is_grouped:
volume = self._sync_status.volume
- if volume is None:
- return None
-
return volume / 100
@property
@@ -529,7 +314,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def source_list(self) -> list[str] | None:
"""List of available input sources."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
sources = [x.text for x in self._inputs]
@@ -540,7 +325,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def source(self) -> str | None:
"""Name of the current input source."""
- if self._status is None or (self.is_grouped and not self.is_leader):
+ if self.available is False or (self.is_grouped and not self.is_leader):
return None
if self._status.input_id is not None:
@@ -557,7 +342,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag of media commands that are supported."""
- if self._status is None:
+ if self.available is False:
return MediaPlayerEntityFeature(0)
if self.is_grouped and not self.is_leader:
@@ -659,16 +444,21 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.sync_status.leader is None and self.sync_status.followers is None:
return []
- player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
+ config_entries: list[BluesoundConfigEntry] = (
+ self.hass.config_entries.async_entries(DOMAIN)
+ )
+ sync_status_list = [
+ x.runtime_data.coordinator.data.sync_status for x in config_entries
+ ]
leader_sync_status: SyncStatus | None = None
if self.sync_status.leader is None:
leader_sync_status = self.sync_status
else:
required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
- for x in player_entities:
- if x.sync_status.id == required_id:
- leader_sync_status = x.sync_status
+ for sync_status in sync_status_list:
+ if sync_status.id == required_id:
+ leader_sync_status = sync_status
break
if leader_sync_status is None or leader_sync_status.followers is None:
@@ -676,9 +466,9 @@ class BluesoundPlayer(MediaPlayerEntity):
follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers]
follower_names = [
- x.sync_status.name
- for x in player_entities
- if x.sync_status.id in follower_ids
+ sync_status.name
+ for sync_status in sync_status_list
+ if sync_status.id in follower_ids
]
follower_names.insert(0, leader_sync_status.name)
return follower_names
diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py
index 645adfdcd2d..c46ef22803e 100644
--- a/homeassistant/components/bluetooth/__init__.py
+++ b/homeassistant/components/bluetooth/__init__.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import datetime
import logging
import platform
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import (
@@ -22,6 +22,7 @@ from bluetooth_adapters import (
adapter_model,
adapter_unique_name,
get_adapters,
+ get_manufacturer_from_mac,
)
from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import (
@@ -51,7 +52,7 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.loader import async_get_bluetooth
-from . import passive_update_processor
+from . import passive_update_processor, websocket_api
from .api import (
_get_manager,
async_address_present,
@@ -66,6 +67,7 @@ from .api import (
async_rediscover_address,
async_register_callback,
async_register_scanner,
+ async_remove_scanner,
async_scanner_by_source,
async_scanner_count,
async_scanner_devices_by_address,
@@ -77,6 +79,10 @@ from .const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_PASSIVE,
+ CONF_SOURCE_CONFIG_ENTRY_ID,
+ CONF_SOURCE_DEVICE_ID,
+ CONF_SOURCE_DOMAIN,
+ CONF_SOURCE_MODEL,
DOMAIN,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
@@ -92,9 +98,24 @@ if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
__all__ = [
+ "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
+ "MONOTONIC_TIME",
+ "SOURCE_LOCAL",
+ "BaseHaRemoteScanner",
+ "BaseHaScanner",
+ "BluetoothCallback",
+ "BluetoothCallbackMatcher",
+ "BluetoothChange",
+ "BluetoothScannerDevice",
+ "BluetoothScanningMode",
+ "BluetoothServiceInfo",
+ "BluetoothServiceInfoBleak",
+ "HaBluetoothConnector",
+ "HomeAssistantRemoteScanner",
"async_address_present",
"async_ble_device_from_address",
"async_discovered_service_info",
+ "async_get_advertisement_callback",
"async_get_fallback_availability_interval",
"async_get_learned_advertising_interval",
"async_get_scanner",
@@ -103,26 +124,12 @@ __all__ = [
"async_rediscover_address",
"async_register_callback",
"async_register_scanner",
- "async_set_fallback_availability_interval",
- "async_track_unavailable",
+ "async_remove_scanner",
"async_scanner_by_source",
"async_scanner_count",
"async_scanner_devices_by_address",
- "async_get_advertisement_callback",
- "BaseHaScanner",
- "HomeAssistantRemoteScanner",
- "BluetoothCallbackMatcher",
- "BluetoothChange",
- "BluetoothServiceInfo",
- "BluetoothServiceInfoBleak",
- "BluetoothScanningMode",
- "BluetoothCallback",
- "BluetoothScannerDevice",
- "HaBluetoothConnector",
- "BaseHaRemoteScanner",
- "SOURCE_LOCAL",
- "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
- "MONOTONIC_TIME",
+ "async_set_fallback_availability_interval",
+ "async_track_unavailable",
]
_LOGGER = logging.getLogger(__name__)
@@ -232,6 +239,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
set_manager(manager)
await storage_setup_task
await manager.async_setup()
+ websocket_api.async_setup(hass)
hass.async_create_background_task(
_async_start_adapter_discovery(hass, manager, bluetooth_adapters),
@@ -290,7 +298,11 @@ async def async_discover_adapters(
async def async_update_device(
- hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ adapter: str,
+ details: AdapterDetails,
+ via_device_id: str | None = None,
) -> None:
"""Update device registry entry.
@@ -299,7 +311,8 @@ async def async_update_device(
update the device with the new location so they can
figure out where the adapter is.
"""
- dr.async_get(hass).async_get_or_create(
+ device_registry = dr.async_get(hass)
+ device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
@@ -308,10 +321,48 @@ async def async_update_device(
sw_version=details.get(ADAPTER_SW_VERSION),
hw_version=details.get(ADAPTER_HW_VERSION),
)
+ if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)):
+ kwargs: dict[str, Any] = {"via_device_id": via_device_id}
+ if not device_entry.area_id and via_device_entry.area_id:
+ kwargs["area_id"] = via_device_entry.area_id
+ device_registry.async_update_device(device_entry.id, **kwargs)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry for a bluetooth scanner."""
+ if source_entry_id := entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID):
+ if not (source_entry := hass.config_entries.async_get_entry(source_entry_id)):
+ # Cleanup the orphaned entry using a call_soon to ensure
+ # we can return before the entry is removed
+ hass.loop.call_soon(
+ hass_callback(
+ lambda: hass.async_create_task(
+ hass.config_entries.async_remove(entry.entry_id),
+ "remove orphaned bluetooth entry {entry.entry_id}",
+ )
+ )
+ )
+ address = entry.unique_id
+ assert address is not None
+ assert source_entry is not None
+ source_domain = entry.data[CONF_SOURCE_DOMAIN]
+ if mac_manufacturer := await get_manufacturer_from_mac(address):
+ manufacturer = f"{mac_manufacturer} ({source_domain})"
+ else:
+ manufacturer = source_domain
+ details = AdapterDetails(
+ address=address,
+ product=entry.data.get(CONF_SOURCE_MODEL),
+ manufacturer=manufacturer,
+ )
+ await async_update_device(
+ hass,
+ entry,
+ source_entry.title,
+ details,
+ entry.data.get(CONF_SOURCE_DEVICE_ID),
+ )
+ return True
manager = _get_manager(hass)
address = entry.unique_id
assert address is not None
diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py
index 7c3d1bc3620..03c278d6b0d 100644
--- a/homeassistant/components/bluetooth/active_update_coordinator.py
+++ b/homeassistant/components/bluetooth/active_update_coordinator.py
@@ -132,7 +132,7 @@ class ActiveBluetoothDataUpdateCoordinator[_T](PassiveBluetoothDataUpdateCoordin
)
self.last_poll_successful = False
return
- except Exception: # noqa: BLE001
+ except Exception:
if self.last_poll_successful:
self.logger.exception("%s: Failure while polling", self.address)
self.last_poll_successful = False
diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py
index e7b65067070..8a23de682e6 100644
--- a/homeassistant/components/bluetooth/active_update_processor.py
+++ b/homeassistant/components/bluetooth/active_update_processor.py
@@ -127,7 +127,7 @@ class ActiveBluetoothProcessorCoordinator[_DataT](
)
self.last_poll_successful = False
return
- except Exception: # noqa: BLE001
+ except Exception:
if self.last_poll_successful:
self.logger.exception("%s: Failure while polling", self.address)
self.last_poll_successful = False
diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py
index 505651edafd..00e585fa266 100644
--- a/homeassistant/components/bluetooth/api.py
+++ b/homeassistant/components/bluetooth/api.py
@@ -178,9 +178,26 @@ def async_register_scanner(
hass: HomeAssistant,
scanner: BaseHaScanner,
connection_slots: int | None = None,
+ source_domain: str | None = None,
+ source_model: str | None = None,
+ source_config_entry_id: str | None = None,
+ source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
- return _get_manager(hass).async_register_scanner(scanner, connection_slots)
+ return _get_manager(hass).async_register_hass_scanner(
+ scanner,
+ connection_slots,
+ source_domain,
+ source_model,
+ source_config_entry_id,
+ source_device_id,
+ )
+
+
+@hass_callback
+def async_remove_scanner(hass: HomeAssistant, source: str) -> None:
+ """Permanently remove a BleakScanner by source address."""
+ return _get_manager(hass).async_remove_scanner(source)
@hass_callback
diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py
index 37eefd2f265..e76277306f5 100644
--- a/homeassistant/components/bluetooth/config_flow.py
+++ b/homeassistant/components/bluetooth/config_flow.py
@@ -18,7 +18,12 @@ from habluetooth import get_manager
import voluptuous as vol
from homeassistant.components import onboarding
-from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
@@ -26,7 +31,17 @@ from homeassistant.helpers.schema_config_entry_flow import (
)
from homeassistant.helpers.typing import DiscoveryInfoType
-from .const import CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, DOMAIN
+from .const import (
+ CONF_ADAPTER,
+ CONF_DETAILS,
+ CONF_PASSIVE,
+ CONF_SOURCE,
+ CONF_SOURCE_CONFIG_ENTRY_ID,
+ CONF_SOURCE_DEVICE_ID,
+ CONF_SOURCE_DOMAIN,
+ CONF_SOURCE_MODEL,
+ DOMAIN,
+)
from .util import adapter_title
OPTIONS_SCHEMA = vol.Schema(
@@ -63,6 +78,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: DiscoveryInfoType
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
+ if discovery_info and CONF_SOURCE in discovery_info:
+ return await self.async_step_external_scanner(discovery_info)
self._adapter = cast(str, discovery_info[CONF_ADAPTER])
self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS])
await self.async_set_unique_id(self._details[ADAPTER_ADDRESS])
@@ -123,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
title=adapter_title(adapter, details), data={}
)
- configured_addresses = self._async_current_ids()
+ configured_addresses = self._async_current_ids(include_ignore=False)
bluetooth_adapters = get_adapters()
await bluetooth_adapters.refresh()
self._adapters = bluetooth_adapters.adapters
@@ -138,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
]
if not unconfigured_adapters:
- ignored_adapters = len(
- self._async_current_entries(include_ignore=True)
- ) - len(self._async_current_entries(include_ignore=False))
return self.async_abort(
reason="no_adapters",
- description_placeholders={"ignored_adapters": str(ignored_adapters)},
)
if len(unconfigured_adapters) == 1:
self._adapter = list(self._adapters)[0]
@@ -167,6 +180,25 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
+ async def async_step_external_scanner(
+ self, user_input: dict[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by an external scanner."""
+ source = user_input[CONF_SOURCE]
+ await self.async_set_unique_id(source)
+ data = {
+ CONF_SOURCE: source,
+ CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
+ CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
+ CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
+ CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
+ }
+ self._abort_if_unique_id_configured(updates=data)
+ manager = get_manager()
+ scanner = manager.async_scanner_by_source(source)
+ assert scanner is not None
+ return self.async_create_entry(title=scanner.name, data=data)
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -177,8 +209,16 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
- ) -> SchemaOptionsFlowHandler:
+ ) -> (
+ SchemaOptionsFlowHandler
+ | RemoteAdapterOptionsFlowHandler
+ | LocalNoPassiveOptionsFlowHandler
+ ):
"""Get the options flow for this handler."""
+ if CONF_SOURCE in config_entry.data:
+ return RemoteAdapterOptionsFlowHandler()
+ if not (manager := get_manager()) or not manager.supports_passive_scan:
+ return LocalNoPassiveOptionsFlowHandler()
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
@classmethod
@@ -186,3 +226,23 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
"""Return options flow support for this handler."""
return bool((manager := get_manager()) and manager.supports_passive_scan)
+
+
+class RemoteAdapterOptionsFlowHandler(OptionsFlow):
+ """Handle a option flow for remote adapters."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle options flow."""
+ return self.async_abort(reason="remote_adapters_not_supported")
+
+
+class LocalNoPassiveOptionsFlowHandler(OptionsFlow):
+ """Handle a option flow for local adapters with no passive support."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle options flow."""
+ return self.async_abort(reason="local_adapters_no_passive_support")
diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py
index a3238befbb8..22c885b4f8b 100644
--- a/homeassistant/components/bluetooth/const.py
+++ b/homeassistant/components/bluetooth/const.py
@@ -18,6 +18,12 @@ CONF_DETAILS = "details"
CONF_PASSIVE = "passive"
+CONF_SOURCE: Final = "source"
+CONF_SOURCE_DOMAIN: Final = "source_domain"
+CONF_SOURCE_MODEL: Final = "source_model"
+CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
+CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
+
SOURCE_LOCAL: Final = "local"
DATA_MANAGER: Final = "bluetooth_manager"
diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py
index e192423484c..46c5425c730 100644
--- a/homeassistant/components/bluetooth/manager.py
+++ b/homeassistant/components/bluetooth/manager.py
@@ -22,7 +22,14 @@ from homeassistant.core import (
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from .const import DOMAIN
+from .const import (
+ CONF_SOURCE,
+ CONF_SOURCE_CONFIG_ENTRY_ID,
+ CONF_SOURCE_DEVICE_ID,
+ CONF_SOURCE_DOMAIN,
+ CONF_SOURCE_MODEL,
+ DOMAIN,
+)
from .match import (
ADDRESS,
CALLBACK,
@@ -44,11 +51,11 @@ class HomeAssistantBluetoothManager(BluetoothManager):
"""Manage Bluetooth for Home Assistant."""
__slots__ = (
- "hass",
- "storage",
- "_integration_matcher",
"_callback_index",
"_cancel_logging_listener",
+ "_integration_matcher",
+ "hass",
+ "storage",
)
def __init__(
@@ -240,6 +247,38 @@ class HomeAssistantBluetoothManager(BluetoothManager):
unregister()
self._async_save_scanner_history(scanner)
+ @hass_callback
+ def async_register_hass_scanner(
+ self,
+ scanner: BaseHaScanner,
+ connection_slots: int | None = None,
+ source_domain: str | None = None,
+ source_model: str | None = None,
+ source_config_entry_id: str | None = None,
+ source_device_id: str | None = None,
+ ) -> CALLBACK_TYPE:
+ """Register a scanner."""
+ cancel = self.async_register_scanner(scanner, connection_slots)
+ if (
+ isinstance(scanner, BaseHaRemoteScanner)
+ and source_domain
+ and source_config_entry_id
+ ):
+ self.hass.async_create_task(
+ self.hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
+ data={
+ CONF_SOURCE: scanner.source,
+ CONF_SOURCE_DOMAIN: source_domain,
+ CONF_SOURCE_MODEL: source_model,
+ CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
+ CONF_SOURCE_DEVICE_ID: source_device_id,
+ },
+ )
+ )
+ return cancel
+
def async_register_scanner(
self,
scanner: BaseHaScanner,
@@ -253,6 +292,18 @@ class HomeAssistantBluetoothManager(BluetoothManager):
unregister = super().async_register_scanner(scanner, connection_slots)
return partial(self._async_unregister_scanner, scanner, unregister)
+ @hass_callback
+ def async_remove_scanner(self, source: str) -> None:
+ """Remove a scanner."""
+ self.storage.async_remove_advertisement_history(source)
+ if entry := self.hass.config_entries.async_entry_for_domain_unique_id(
+ DOMAIN, source
+ ):
+ self.hass.async_create_task(
+ self.hass.config_entries.async_remove(entry.entry_id),
+ f"Removing {source} Bluetooth config entry",
+ )
+
@hass_callback
def _handle_config_entry_removed(
self,
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index ef1ec6a8936..5d2b8ab6285 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -10,16 +10,17 @@
"btsocket",
"bleak_retry_connector",
"bluetooth_adapters",
- "bluetooth_auto_recovery"
+ "bluetooth_auto_recovery",
+ "habluetooth"
],
"quality_scale": "internal",
"requirements": [
"bleak==0.22.3",
- "bleak-retry-connector==3.6.0",
- "bluetooth-adapters==0.20.2",
+ "bleak-retry-connector==3.8.1",
+ "bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.2",
- "bluetooth-data-tools==1.20.0",
- "dbus-fast==2.24.3",
- "habluetooth==3.7.0"
+ "bluetooth-data-tools==1.23.4",
+ "dbus-fast==2.33.0",
+ "habluetooth==3.21.1"
]
}
diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py
index ee62420b692..6307d3ca93b 100644
--- a/homeassistant/components/bluetooth/match.py
+++ b/homeassistant/components/bluetooth/match.py
@@ -92,7 +92,7 @@ def seen_all_fields(
class IntegrationMatcher:
"""Integration matcher for the bluetooth integration."""
- __slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index")
+ __slots__ = ("_index", "_integration_matchers", "_matched", "_matched_connectable")
def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None:
"""Initialize the matcher."""
@@ -164,12 +164,12 @@ class BluetoothMatcherIndexBase[
__slots__ = (
"local_name",
- "service_uuid",
- "service_data_uuid",
"manufacturer_id",
- "service_uuid_set",
- "service_data_uuid_set",
"manufacturer_id_set",
+ "service_data_uuid",
+ "service_data_uuid_set",
+ "service_uuid",
+ "service_uuid_set",
)
def __init__(self) -> None:
diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py
index be232f87b24..ccff85e5027 100644
--- a/homeassistant/components/bluetooth/passive_update_coordinator.py
+++ b/homeassistant/components/bluetooth/passive_update_coordinator.py
@@ -4,8 +4,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
-from typing_extensions import TypeVar
-
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
@@ -20,12 +18,6 @@ if TYPE_CHECKING:
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
-_PassiveBluetoothDataUpdateCoordinatorT = TypeVar(
- "_PassiveBluetoothDataUpdateCoordinatorT",
- bound="PassiveBluetoothDataUpdateCoordinator",
- default="PassiveBluetoothDataUpdateCoordinator",
-)
-
class PassiveBluetoothDataUpdateCoordinator(
BasePassiveBluetoothCoordinator, BaseDataUpdateCoordinatorProtocol
@@ -98,7 +90,9 @@ class PassiveBluetoothDataUpdateCoordinator(
self.async_update_listeners()
-class PassiveBluetoothCoordinatorEntity( # pylint: disable=hass-enforce-class-module
+class PassiveBluetoothCoordinatorEntity[
+ _PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator
+]( # pylint: disable=hass-enforce-class-module
BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT]
):
"""A class for entities using DataUpdateCoordinator."""
diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py
index 6b4c7695fd2..369db4a7760 100644
--- a/homeassistant/components/bluetooth/storage.py
+++ b/homeassistant/components/bluetooth/storage.py
@@ -38,6 +38,12 @@ class BluetoothStorage:
"""Get all scanners."""
return list(self._data.keys())
+ @callback
+ def async_remove_advertisement_history(self, scanner: str) -> None:
+ """Remove discovered devices by scanner."""
+ if self._data.pop(scanner, None):
+ self._store.async_delay_save(self._async_get_data, SCANNER_SAVE_DELAY)
+
@callback
def async_get_advertisement_history(
self, scanner: str
diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json
index c28bd3cc65e..866b76c0985 100644
--- a/homeassistant/components/bluetooth/strings.json
+++ b/homeassistant/components/bluetooth/strings.json
@@ -23,7 +23,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
- "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters."
+ "no_adapters": "No unconfigured Bluetooth adapters found."
}
},
"options": {
@@ -33,6 +33,10 @@
"passive": "Passive scanning"
}
}
+ },
+ "abort": {
+ "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported.",
+ "local_adapters_no_passive_support": "Local Bluetooth adapters that do not support passive scanning cannot be configured."
}
}
}
diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py
index 8c7ad13294a..738a61b6f33 100644
--- a/homeassistant/components/bluetooth/util.py
+++ b/homeassistant/components/bluetooth/util.py
@@ -11,13 +11,23 @@ from bluetooth_adapters import (
adapter_unique_name,
)
from bluetooth_data_tools import monotonic_time_coarse
+from habluetooth import get_manager
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from .models import BluetoothServiceInfoBleak
from .storage import BluetoothStorage
+class InvalidConfigEntryID(HomeAssistantError):
+ """Invalid config entry id."""
+
+
+class InvalidSource(HomeAssistantError):
+ """Invalid source."""
+
+
@callback
def async_load_history_from_system(
adapters: BluetoothAdapters, storage: BluetoothStorage
@@ -29,6 +39,10 @@ def async_load_history_from_system(
now_monotonic = monotonic_time_coarse()
connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
+ adapter_to_source_address = {
+ adapter: details[ADAPTER_ADDRESS]
+ for adapter, details in adapters.adapters.items()
+ }
# Restore local adapters
for address, history in adapters.history.items():
@@ -40,7 +54,11 @@ def async_load_history_from_system(
BluetoothServiceInfoBleak.from_device_and_advertisement_data(
history.device,
history.advertisement_data,
- history.source,
+ # history.source is really the adapter name
+ # for historical compatibility since BlueZ
+ # does not know the MAC address of the adapter
+ # so we need to convert it to the source address (MAC)
+ adapter_to_source_address.get(history.source, history.source),
now_monotonic,
True,
)
@@ -85,3 +103,14 @@ def adapter_title(adapter: str, details: AdapterDetails) -> str:
model = details.get(ADAPTER_PRODUCT, "Unknown")
manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown"
return f"{manufacturer} {model} ({unique_name})"
+
+
+def config_entry_id_to_source(hass: HomeAssistant, config_entry_id: str) -> str:
+ """Convert a config entry id to a source."""
+ if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
+ raise InvalidConfigEntryID(f"Config entry {config_entry_id} not found")
+ source = entry.unique_id
+ assert source is not None
+ if not get_manager().async_scanner_by_source(source):
+ raise InvalidSource(f"Source {source} not found")
+ return source
diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py
new file mode 100644
index 00000000000..d21b11b050f
--- /dev/null
+++ b/homeassistant/components/bluetooth/websocket_api.py
@@ -0,0 +1,255 @@
+"""The bluetooth integration websocket apis."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable
+from functools import lru_cache, partial
+import time
+from typing import Any
+
+from habluetooth import (
+ BluetoothScanningMode,
+ HaBluetoothSlotAllocations,
+ HaScannerRegistration,
+ HaScannerRegistrationEvent,
+)
+from home_assistant_bluetooth import BluetoothServiceInfoBleak
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.json import json_bytes
+
+from .api import _get_manager, async_register_callback
+from .const import DOMAIN
+from .match import BluetoothCallbackMatcher
+from .models import BluetoothChange
+from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source
+
+
+@callback
+def async_setup(hass: HomeAssistant) -> None:
+ """Set up the bluetooth websocket API."""
+ websocket_api.async_register_command(hass, ws_subscribe_advertisements)
+ websocket_api.async_register_command(hass, ws_subscribe_connection_allocations)
+ websocket_api.async_register_command(hass, ws_subscribe_scanner_details)
+
+
+@lru_cache(maxsize=1024)
+def serialize_service_info(
+ service_info: BluetoothServiceInfoBleak, time_diff: float
+) -> dict[str, Any]:
+ """Serialize a BluetoothServiceInfoBleak object."""
+ return {
+ "name": service_info.name,
+ "address": service_info.address,
+ "rssi": service_info.rssi,
+ "manufacturer_data": {
+ str(manufacturer_id): manufacturer_data.hex()
+ for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items()
+ },
+ "service_data": {
+ service_uuid: service_data.hex()
+ for service_uuid, service_data in service_info.service_data.items()
+ },
+ "service_uuids": service_info.service_uuids,
+ "source": service_info.source,
+ "connectable": service_info.connectable,
+ "time": service_info.time + time_diff,
+ "tx_power": service_info.tx_power,
+ }
+
+
+class _AdvertisementSubscription:
+ """Class to hold and manage the subscription data."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ ws_msg_id: int,
+ match_dict: BluetoothCallbackMatcher,
+ ) -> None:
+ """Initialize the subscription data."""
+ self.hass = hass
+ self.match_dict = match_dict
+ self.pending_service_infos: list[BluetoothServiceInfoBleak] = []
+ self.ws_msg_id = ws_msg_id
+ self.connection = connection
+ self.pending = True
+ # Keep time_diff precise to 2 decimal places
+ # so the cached serialization can be reused,
+ # however we still want to calculate it each
+ # subscription in case the system clock is wrong
+ # and gets corrected.
+ self.time_diff = round(time.time() - time.monotonic(), 2)
+
+ @callback
+ def _async_unsubscribe(
+ self, cancel_callbacks: tuple[Callable[[], None], ...]
+ ) -> None:
+ """Unsubscribe the callback."""
+ for cancel_callback in cancel_callbacks:
+ cancel_callback()
+
+ @callback
+ def async_start(self) -> None:
+ """Start the subscription."""
+ connection = self.connection
+ cancel_adv_callback = async_register_callback(
+ self.hass,
+ self._async_on_advertisement,
+ self.match_dict,
+ BluetoothScanningMode.PASSIVE,
+ )
+ cancel_disappeared_callback = _get_manager(
+ self.hass
+ ).async_register_disappeared_callback(self._async_removed)
+ connection.subscriptions[self.ws_msg_id] = partial(
+ self._async_unsubscribe, (cancel_adv_callback, cancel_disappeared_callback)
+ )
+ self.pending = False
+ self.connection.send_message(
+ json_bytes(websocket_api.result_message(self.ws_msg_id))
+ )
+ self._async_added(self.pending_service_infos)
+ self.pending_service_infos.clear()
+
+ def _async_event_message(self, message: dict[str, Any]) -> None:
+ self.connection.send_message(
+ json_bytes(websocket_api.event_message(self.ws_msg_id, message))
+ )
+
+ def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None:
+ self._async_event_message(
+ {
+ "add": [
+ serialize_service_info(service_info, self.time_diff)
+ for service_info in service_infos
+ ]
+ }
+ )
+
+ def _async_removed(self, address: str) -> None:
+ self._async_event_message({"remove": [{"address": address}]})
+
+ @callback
+ def _async_on_advertisement(
+ self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange
+ ) -> None:
+ """Handle the callback."""
+ if self.pending:
+ self.pending_service_infos.append(service_info)
+ return
+ self._async_added((service_info,))
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "bluetooth/subscribe_advertisements",
+ }
+)
+@websocket_api.async_response
+async def ws_subscribe_advertisements(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Handle subscribe advertisements websocket command."""
+ _AdvertisementSubscription(
+ hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False)
+ ).async_start()
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "bluetooth/subscribe_connection_allocations",
+ vol.Optional("config_entry_id"): str,
+ }
+)
+@websocket_api.async_response
+async def ws_subscribe_connection_allocations(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Handle subscribe advertisements websocket command."""
+ ws_msg_id = msg["id"]
+ source: str | None = None
+ if config_entry_id := msg.get("config_entry_id"):
+ try:
+ source = config_entry_id_to_source(hass, config_entry_id)
+ except InvalidConfigEntryID as err:
+ connection.send_error(ws_msg_id, "invalid_config_entry_id", str(err))
+ return
+ except InvalidSource as err:
+ connection.send_error(ws_msg_id, "invalid_source", str(err))
+ return
+
+ def _async_allocations_changed(allocations: HaBluetoothSlotAllocations) -> None:
+ connection.send_message(
+ json_bytes(websocket_api.event_message(ws_msg_id, [allocations]))
+ )
+
+ manager = _get_manager(hass)
+ connection.subscriptions[ws_msg_id] = manager.async_register_allocation_callback(
+ _async_allocations_changed, source
+ )
+ connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id)))
+ if current_allocations := manager.async_current_allocations(source):
+ connection.send_message(
+ json_bytes(websocket_api.event_message(ws_msg_id, current_allocations))
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "bluetooth/subscribe_scanner_details",
+ vol.Optional("config_entry_id"): str,
+ }
+)
+@websocket_api.async_response
+async def ws_subscribe_scanner_details(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Handle subscribe scanner details websocket command."""
+ ws_msg_id = msg["id"]
+ source: str | None = None
+ if config_entry_id := msg.get("config_entry_id"):
+ if (
+ not (entry := hass.config_entries.async_get_entry(config_entry_id))
+ or entry.domain != DOMAIN
+ ):
+ connection.send_error(
+ ws_msg_id,
+ "invalid_config_entry_id",
+ f"Invalid config entry id: {config_entry_id}",
+ )
+ return
+ source = entry.unique_id
+ assert source is not None
+
+ def _async_event_message(message: dict[str, Any]) -> None:
+ connection.send_message(
+ json_bytes(websocket_api.event_message(ws_msg_id, message))
+ )
+
+ def _async_registration_changed(registration: HaScannerRegistration) -> None:
+ added_event = HaScannerRegistrationEvent.ADDED
+ event_type = "add" if registration.event == added_event else "remove"
+ _async_event_message({event_type: [registration.scanner.details]})
+
+ manager = _get_manager(hass)
+ connection.subscriptions[ws_msg_id] = (
+ manager.async_register_scanner_registration_callback(
+ _async_registration_changed, source
+ )
+ )
+ connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id)))
+ if (scanners := manager.async_current_scanners()) and (
+ matching_scanners := [
+ scanner.details
+ for scanner in scanners
+ if source is None or scanner.source == source
+ ]
+ ):
+ _async_event_message({"add": matching_scanners})
diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py
index 25e620ff15d..25a1aa60a1d 100644
--- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py
+++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py
@@ -24,10 +24,10 @@ from homeassistant.components.device_tracker.legacy import (
)
from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py
index 1d64d31a248..17d166f2b32 100644
--- a/homeassistant/components/bluetooth_tracker/device_tracker.py
+++ b/homeassistant/components/bluetooth_tracker/device_tracker.py
@@ -27,7 +27,7 @@ from homeassistant.components.device_tracker.legacy import (
)
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
index 05fa3e3cab0..287cb226b51 100644
--- a/homeassistant/components/bmw_connected_drive/__init__.py
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -9,11 +9,11 @@ import voluptuous as vol
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
+ config_validation as cv,
device_registry as dr,
discovery,
entity_registry as er,
)
-import homeassistant.helpers.config_validation as cv
from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN
from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator
diff --git a/homeassistant/components/bmw_connected_drive/quality_scale.yaml b/homeassistant/components/bmw_connected_drive/quality_scale.yaml
new file mode 100644
index 00000000000..bc3bd517662
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/quality_scale.yaml
@@ -0,0 +1,107 @@
+# + in comment indicates requirement for quality scale
+# - in comment indicates issue to be fixed, not impacting quality scale
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ Does not have custom services
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: done
+ comment: |
+ - 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update()
+ config-flow-test-coverage:
+ status: todo
+ comment: |
+ - test_show_form doesn't really add anything
+ - Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports
+ + Ensure that configs flows end in CREATE_ENTRY or ABORT
+ - Parameterize test_authentication_error, test_api_error and test_connection_error
+ + test_full_user_flow_implementation doesn't assert unique id of created entry
+ + test that aborts when a mocked config entry already exists
+ + don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change)
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ Does not have custom services
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ This integration doesn't have any events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ Does not have custom services
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage:
+ status: done
+ comment: |
+ - Use constants in tests where possible
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: This integration doesn't use discovery.
+ discovery:
+ status: exempt
+ comment: This integration doesn't use discovery.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices:
+ status: todo
+ comment: >
+ To be discussed.
+ We cannot regularly get new devices/vehicles due to API quota limitations.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ Other than reauthentication, this integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: todo
+ comment: >
+ To be discussed.
+ We cannot regularly check for stale devices/vehicles due to API quota limitations.
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: todo
+ comment: >
+ To be discussed.
+ The library requires a custom client for API authentication, with custom auth lifecycle and user agents.
+ strict-typing: done
diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py
index a12d3057258..38abd63186a 100644
--- a/homeassistant/components/bond/config_flow.py
+++ b/homeassistant/components/bond/config_flow.py
@@ -11,12 +11,12 @@ from aiohttp import ClientConnectionError, ClientResponseError
from bond_async import Bond
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .utils import BondHub
@@ -97,7 +97,7 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_NAME] = hub_name
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
name: str = discovery_info.name
diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py
index 81f96b1772c..2ae1df5fd68 100644
--- a/homeassistant/components/bond/entity.py
+++ b/homeassistant/components/bond/entity.py
@@ -115,11 +115,8 @@ class BondEntity(Entity):
def _async_update_if_bpup_not_alive(self, now: datetime) -> None:
"""Fetch via the API if BPUP is not alive."""
self._async_schedule_bpup_alive_or_poll()
- if (
- self.hass.is_stopping
- or self._bpup_subs.alive
- and self._initialized
- and self.available
+ if self.hass.is_stopping or (
+ self._bpup_subs.alive and self._initialized and self.available
):
return
if self._update_lock.locked():
diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py
index 9a00029412d..2871bc52450 100644
--- a/homeassistant/components/bosch_shc/__init__.py
+++ b/homeassistant/components/bosch_shc/__init__.py
@@ -12,13 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
-from .const import (
- CONF_SSL_CERTIFICATE,
- CONF_SSL_KEY,
- DATA_POLLING_HANDLER,
- DATA_SESSION,
- DOMAIN,
-)
+from .const import CONF_SSL_CERTIFICATE, CONF_SSL_KEY, DOMAIN
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -30,7 +24,10 @@ PLATFORMS = [
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type BoschConfigEntry = ConfigEntry[SHCSession]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> bool:
"""Set up Bosch SHC from a config entry."""
data = entry.data
@@ -53,10 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if shc_info.updateState.name == "UPDATE_AVAILABLE":
_LOGGER.warning("Please check for software updates in the Bosch Smart Home App")
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_SESSION: session,
- }
+ entry.runtime_data = session
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
@@ -76,23 +70,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.async_add_executor_job(session.stop_polling)
await hass.async_add_executor_job(session.start_polling)
- hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER] = (
+ entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_polling)
)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> bool:
"""Unload a config entry."""
- session: SHCSession = hass.data[DOMAIN][entry.entry_id][DATA_SESSION]
+ await hass.async_add_executor_job(entry.runtime_data.stop_polling)
- hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER]()
- hass.data[DOMAIN][entry.entry_id].pop(DATA_POLLING_HANDLER)
- await hass.async_add_executor_job(session.stop_polling)
-
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py
index 342a3e3e417..dd0f31ea6f9 100644
--- a/homeassistant/components/bosch_shc/binary_sensor.py
+++ b/homeassistant/components/bosch_shc/binary_sensor.py
@@ -2,28 +2,27 @@
from __future__ import annotations
-from boschshcpy import SHCBatteryDevice, SHCSession, SHCShutterContact
+from boschshcpy import SHCBatteryDevice, SHCShutterContact
from boschshcpy.device import SHCDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_SESSION, DOMAIN
+from . import BoschConfigEntry
from .entity import SHCEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: BoschConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SHC binary sensor platform."""
- session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION]
+ session = config_entry.runtime_data
entities: list[BinarySensorEntity] = [
ShutterContactSensor(
diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py
index 58601152da5..c234000674d 100644
--- a/homeassistant/components/bosch_shc/config_flow.py
+++ b/homeassistant/components/bosch_shc/config_flow.py
@@ -20,6 +20,7 @@ from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_HOSTNAME,
@@ -217,7 +218,7 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if not discovery_info.name.startswith("Bosch SHC"):
diff --git a/homeassistant/components/bosch_shc/const.py b/homeassistant/components/bosch_shc/const.py
index ccb1f2094cb..07ec3b7da85 100644
--- a/homeassistant/components/bosch_shc/const.py
+++ b/homeassistant/components/bosch_shc/const.py
@@ -6,7 +6,4 @@ CONF_SHC_KEY = "bosch_shc-key.pem"
CONF_SSL_CERTIFICATE = "ssl_certificate"
CONF_SSL_KEY = "ssl_key"
-DATA_SESSION = "session"
-DATA_POLLING_HANDLER = "polling_handler"
-
DOMAIN = "bosch_shc"
diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py
index 5377f0c6a8f..55d6bfc35de 100644
--- a/homeassistant/components/bosch_shc/cover.py
+++ b/homeassistant/components/bosch_shc/cover.py
@@ -2,7 +2,7 @@
from typing import Any
-from boschshcpy import SHCSession, SHCShutterControl
+from boschshcpy import SHCShutterControl
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -10,22 +10,20 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_SESSION, DOMAIN
+from . import BoschConfigEntry
from .entity import SHCEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: BoschConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SHC cover platform."""
-
- session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION]
+ session = config_entry.runtime_data
async_add_entities(
ShutterControlCover(
diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py
index 28f23cd9765..6408e21654e 100644
--- a/homeassistant/components/bosch_shc/sensor.py
+++ b/homeassistant/components/bosch_shc/sensor.py
@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
-from boschshcpy import SHCSession
from boschshcpy.device import SHCDevice
from homeassistant.components.sensor import (
@@ -15,7 +14,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
@@ -27,7 +25,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import DATA_SESSION, DOMAIN
+from . import BoschConfigEntry
from .entity import SHCEntity
@@ -127,11 +125,11 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: BoschConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SHC sensor platform."""
- session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION]
+ session = config_entry.runtime_data
entities: list[SensorEntity] = [
SHCSensor(
diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json
index 88eb817bbd9..7aa3b0ace32 100644
--- a/homeassistant/components/bosch_shc/strings.json
+++ b/homeassistant/components/bosch_shc/strings.json
@@ -24,7 +24,7 @@
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "The bosch_shc integration needs to re-authenticate your account",
+ "description": "The Bosch SHC integration needs to re-authenticate your account",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
@@ -34,7 +34,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"pairing_failed": "Pairing failed; please check the Bosch Smart Home Controller is in pairing mode (LED flashing) as well as your password is correct.",
- "session_error": "Session error: API return Non-OK result.",
+ "session_error": "Session error: API returned Non-OK result.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py
index 58370a120f2..76b1da3e534 100644
--- a/homeassistant/components/bosch_shc/switch.py
+++ b/homeassistant/components/bosch_shc/switch.py
@@ -9,7 +9,6 @@ from boschshcpy import (
SHCCamera360,
SHCCameraEyes,
SHCLightSwitch,
- SHCSession,
SHCSmartPlug,
SHCSmartPlugCompact,
)
@@ -20,13 +19,12 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import DATA_SESSION, DOMAIN
+from . import BoschConfigEntry
from .entity import SHCEntity
@@ -80,11 +78,11 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: BoschConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SHC switch platform."""
- session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION]
+ session = config_entry.runtime_data
entities: list[SwitchEntity] = [
SHCSwitch(
diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py
index db5c72d7932..5d775b98180 100644
--- a/homeassistant/components/braviatv/config_flow.py
+++ b/homeassistant/components/braviatv/config_flow.py
@@ -10,11 +10,16 @@ from aiohttp import CookieJar
from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSupported
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_create_clientsession
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from homeassistant.util.network import is_host_valid
from .const import (
@@ -202,14 +207,14 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered device."""
# We can cast the hostname to str because the ssdp_location is not bytes and
# not a relative url
host = cast(str, urlparse(discovery_info.ssdp_location).hostname)
- await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN])
+ await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._async_abort_entries_match({CONF_HOST: host})
@@ -221,8 +226,8 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
if "videoScreen" not in service_types:
return self.async_abort(reason="not_bravia_device")
- model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
- friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
+ model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
+ friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]
self.context["title_placeholders"] = {
CONF_NAME: f"{model_name} ({friendly_name})",
diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py
index 80b7a843cc0..0ee8e3b3155 100644
--- a/homeassistant/components/bring/__init__.py
+++ b/homeassistant/components/bring/__init__.py
@@ -4,20 +4,13 @@ from __future__ import annotations
import logging
-from bring_api import (
- Bring,
- BringAuthException,
- BringParseException,
- BringRequestException,
-)
+from bring_api import Bring
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
from .coordinator import BringDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO]
@@ -30,30 +23,8 @@ type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
"""Set up Bring! from a config entry."""
- email = entry.data[CONF_EMAIL]
- password = entry.data[CONF_PASSWORD]
-
session = async_get_clientsession(hass)
- bring = Bring(session, email, password)
-
- try:
- await bring.login()
- except BringRequestException as e:
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="setup_request_exception",
- ) from e
- except BringParseException as e:
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="setup_parse_exception",
- ) from e
- except BringAuthException as e:
- raise ConfigEntryAuthFailed(
- translation_domain=DOMAIN,
- translation_key="setup_authentication_exception",
- translation_placeholders={CONF_EMAIL: email},
- ) from e
+ bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
coordinator = BringDataUpdateCoordinator(hass, bring)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py
index b8ee9d1e6ae..bfb5a2cd50f 100644
--- a/homeassistant/components/bring/config_flow.py
+++ b/homeassistant/components/bring/config_flow.py
@@ -63,7 +63,8 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
):
self._abort_if_unique_id_configured()
return self.async_create_entry(
- title=self.info.get("name") or user_input[CONF_EMAIL], data=user_input
+ title=self.info.name or user_input[CONF_EMAIL],
+ data=user_input,
)
return self.async_show_form(
diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py
index 7678213f117..0511d285afc 100644
--- a/homeassistant/components/bring/coordinator.py
+++ b/homeassistant/components/bring/coordinator.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -12,11 +13,12 @@ from bring_api import (
BringRequestException,
)
from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse
+from mashumaro.mixins.orjson import DataClassORJSONMixin
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -24,9 +26,13 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-class BringData(BringList, BringItemsResponse):
+@dataclass(frozen=True)
+class BringData(DataClassORJSONMixin):
"""Coordinator data class."""
+ lst: BringList
+ content: BringItemsResponse
+
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
"""A Bring Data Update Coordinator."""
@@ -51,7 +57,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e:
raise UpdateFailed("Unable to parse response from bring") from e
- except BringAuthException as e:
+ except BringAuthException:
# try to recover by refreshing access token, otherwise
# initiate reauth flow
try:
@@ -64,14 +70,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.bring.mail},
) from exc
- raise UpdateFailed(
- "Authentication failed but re-authentication was successful, trying again later"
- ) from e
+ return self.data
list_dict: dict[str, BringData] = {}
- for lst in lists_response["lists"]:
+ for lst in lists_response.lists:
+ if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
+ continue
try:
- items = await self.bring.get_list(lst["listUuid"])
+ items = await self.bring.get_list(lst.listUuid)
except BringRequestException as e:
raise UpdateFailed(
"Unable to connect and retrieve data from bring"
@@ -79,20 +85,29 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
except BringParseException as e:
raise UpdateFailed("Unable to parse response from bring") from e
else:
- list_dict[lst["listUuid"]] = BringData(**lst, **items)
+ list_dict[lst.listUuid] = BringData(lst, items)
return list_dict
async def _async_setup(self) -> None:
"""Set up coordinator."""
- await self.async_refresh_user_settings()
-
- async def async_refresh_user_settings(self) -> None:
- """Refresh user settings."""
try:
+ await self.bring.login()
self.user_settings = await self.bring.get_all_user_settings()
- except (BringAuthException, BringRequestException, BringParseException) as e:
- raise UpdateFailed(
- "Unable to connect and retrieve user settings from bring"
+ except BringRequestException as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_request_exception",
+ ) from e
+ except BringParseException as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_parse_exception",
+ ) from e
+ except BringAuthException as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="setup_authentication_exception",
+ translation_placeholders={CONF_EMAIL: self.bring.mail},
) from e
diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py
index f4193a9993c..1dec8f3a5ed 100644
--- a/homeassistant/components/bring/diagnostics.py
+++ b/homeassistant/components/bring/diagnostics.py
@@ -2,15 +2,16 @@
from __future__ import annotations
+from typing import Any
+
from homeassistant.core import HomeAssistant
from . import BringConfigEntry
-from .coordinator import BringData
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: BringConfigEntry
-) -> dict[str, BringData]:
+) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- return config_entry.runtime_data.data
+ return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()}
diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py
index 5b6bf975764..74076d66df9 100644
--- a/homeassistant/components/bring/entity.py
+++ b/homeassistant/components/bring/entity.py
@@ -20,13 +20,13 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
bring_list: BringData,
) -> None:
"""Initialize the entity."""
- super().__init__(coordinator)
+ super().__init__(coordinator, bring_list.lst.listUuid)
- self._list_uuid = bring_list["listUuid"]
+ self._list_uuid = bring_list.lst.listUuid
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
- name=bring_list["name"],
+ name=bring_list.lst.name,
identifiers={
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
},
diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json
index 71fe733ccf5..ecd3e911078 100644
--- a/homeassistant/components/bring/manifest.json
+++ b/homeassistant/components/bring/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
- "requirements": ["bring-api==0.9.1"]
+ "requirements": ["bring-api==1.0.0"]
}
diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py
index bd33ce9bf88..02bd0e50788 100644
--- a/homeassistant/components/bring/sensor.py
+++ b/homeassistant/components/bring/sensor.py
@@ -65,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
translation_key=BringSensor.LIST_LANGUAGE,
value_fn=(
lambda lst, settings: x.lower()
- if (x := list_language(lst["listUuid"], settings))
+ if (x := list_language(lst.lst.listUuid, settings))
else None
),
entity_category=EntityCategory.DIAGNOSTIC,
@@ -75,7 +75,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
BringSensorEntityDescription(
key=BringSensor.LIST_ACCESS,
translation_key=BringSensor.LIST_ACCESS,
- value_fn=lambda lst, _: lst["status"].lower(),
+ value_fn=lambda lst, _: lst.content.status.value.lower(),
entity_category=EntityCategory.DIAGNOSTIC,
options=["registered", "shared", "invitation"],
device_class=SensorDeviceClass.ENUM,
diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json
index e65f9607afb..ea9af03484e 100644
--- a/homeassistant/components/bring/strings.json
+++ b/homeassistant/components/bring/strings.json
@@ -101,11 +101,11 @@
"setup_authentication_exception": {
"message": "Authentication failed for {email}, check your email and password"
},
- "notify_missing_argument_item": {
- "message": "Failed to perform action {service}. 'URGENT_MESSAGE' requires a value @ data['item']. Got None"
+ "notify_missing_argument": {
+ "message": "This action requires field {field}, please enter a valid value for {field}"
},
"notify_request_failed": {
- "message": "Failed to send push notification for bring due to a connection error, try again later"
+ "message": "Failed to send push notification for Bring! due to a connection error, try again later"
}
},
"services": {
@@ -122,8 +122,8 @@
"description": "Type of push notification to send to list members."
},
"item": {
- "name": "Article (Required if notification type `Urgent message` is selected)",
- "description": "Article name to include in an urgent message e.g. `Urgent message - Please buy Cilantro urgently`"
+ "name": "Item (Required if notification type 'Urgent message' is selected)",
+ "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'"
}
}
}
@@ -131,10 +131,10 @@
"selector": {
"notification_type_selector": {
"options": {
- "going_shopping": "I'm going shopping! - Last chance to make changes",
- "changed_list": "List updated - Take a look at the articles",
- "shopping_done": "Shopping done - The fridge is well stocked",
- "urgent_message": "Urgent message - Please buy `Article` urgently"
+ "going_shopping": "I'm going shopping! - Last chance for adjustments",
+ "changed_list": "I changed the list! - Take a look at the items",
+ "shopping_done": "The shopping is done - Our fridge is well stocked",
+ "urgent_message": "Attention! Attention! - We still urgently need: [Items]"
}
}
}
diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py
index c53b5788b68..7ab60084314 100644
--- a/homeassistant/components/bring/todo.py
+++ b/homeassistant/components/bring/todo.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from itertools import chain
from typing import TYPE_CHECKING
import uuid
@@ -59,7 +60,7 @@ async def async_setup_entry(
SERVICE_PUSH_NOTIFICATION,
{
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
- vol.Upper, cv.enum(BringNotificationType)
+ vol.Upper, vol.Coerce(BringNotificationType)
),
vol.Optional(ATTR_ITEM_NAME): cv.string,
},
@@ -92,21 +93,21 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
return [
*(
TodoItem(
- uid=item["uuid"],
- summary=item["itemId"],
- description=item["specification"] or "",
+ uid=item.uuid,
+ summary=item.itemId,
+ description=item.specification,
status=TodoItemStatus.NEEDS_ACTION,
)
- for item in self.bring_list["purchase"]
+ for item in self.bring_list.content.items.purchase
),
*(
TodoItem(
- uid=item["uuid"],
- summary=item["itemId"],
- description=item["specification"] or "",
+ uid=item.uuid,
+ summary=item.itemId,
+ description=item.specification,
status=TodoItemStatus.COMPLETED,
)
- for item in self.bring_list["recently"]
+ for item in self.bring_list.content.items.recently
),
]
@@ -119,7 +120,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
"""Add an item to the To-do list."""
try:
await self.coordinator.bring.save_item(
- self.bring_list["listUuid"],
+ self._list_uuid,
item.summary or "",
item.description or "",
str(uuid.uuid4()),
@@ -154,26 +155,25 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
bring_list = self.bring_list
- bring_purchase_item = next(
- (i for i in bring_list["purchase"] if i["uuid"] == item.uid),
+ current_item = next(
+ (
+ i
+ for i in chain(
+ bring_list.content.items.purchase, bring_list.content.items.recently
+ )
+ if i.uuid == item.uid
+ ),
None,
)
- bring_recently_item = next(
- (i for i in bring_list["recently"] if i["uuid"] == item.uid),
- None,
- )
-
- current_item = bring_purchase_item or bring_recently_item
-
if TYPE_CHECKING:
assert item.uid
assert current_item
- if item.summary == current_item["itemId"]:
+ if item.summary == current_item.itemId:
try:
await self.coordinator.bring.batch_update_list(
- bring_list["listUuid"],
+ self._list_uuid,
BringItem(
itemId=item.summary or "",
spec=item.description or "",
@@ -192,10 +192,10 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
else:
try:
await self.coordinator.bring.batch_update_list(
- bring_list["listUuid"],
+ self._list_uuid,
[
BringItem(
- itemId=current_item["itemId"],
+ itemId=current_item.itemId,
spec=item.description or "",
uuid=item.uid,
operation=BringItemOperation.REMOVE,
@@ -225,7 +225,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
try:
await self.coordinator.bring.batch_update_list(
- self.bring_list["listUuid"],
+ self._list_uuid,
[
BringItem(
itemId=uid,
@@ -262,8 +262,6 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
except ValueError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
- translation_key="notify_missing_argument_item",
- translation_placeholders={
- "service": f"{DOMAIN}.{SERVICE_PUSH_NOTIFICATION}",
- },
+ translation_key="notify_missing_argument",
+ translation_placeholders={"field": "item"},
) from e
diff --git a/homeassistant/components/bring/util.py b/homeassistant/components/bring/util.py
index b706156a3d3..9a075f7bb89 100644
--- a/homeassistant/components/bring/util.py
+++ b/homeassistant/components/bring/util.py
@@ -14,27 +14,25 @@ def list_language(
"""Get the lists language setting."""
try:
list_settings = next(
- filter(
- lambda x: x["listUuid"] == list_uuid,
- user_settings["userlistsettings"],
- )
+ filter(lambda x: x.listUuid == list_uuid, user_settings.userlistsettings)
)
- return next(
- filter(
- lambda x: x["key"] == "listArticleLanguage",
- list_settings["usersettings"],
+ return (
+ next(
+ filter(
+ lambda x: x.key == "listArticleLanguage", list_settings.usersettings
+ )
)
- )["value"]
+ ).value
- except (StopIteration, KeyError):
+ except StopIteration:
return None
def sum_attributes(bring_list: BringData, attribute: str) -> int:
"""Count items with given attribute set."""
return sum(
- item["attributes"][0]["content"][attribute]
- for item in bring_list["purchase"]
- if len(item.get("attributes", []))
+ getattr(item.attributes[0].content, attribute)
+ for item in bring_list.content.items.purchase
+ if item.attributes
)
diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py
index c9b2fb46608..617e466a1b1 100644
--- a/homeassistant/components/broadlink/config_flow.py
+++ b/homeassistant/components/broadlink/config_flow.py
@@ -15,7 +15,6 @@ from broadlink.exceptions import (
)
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
SOURCE_IMPORT,
SOURCE_REAUTH,
@@ -25,6 +24,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN
from .helpers import format_mac
@@ -65,7 +65,7 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
}
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
host = discovery_info.ip
diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py
index 75b6236a473..082af07ebbd 100644
--- a/homeassistant/components/broadlink/device.py
+++ b/homeassistant/components/broadlink/device.py
@@ -3,7 +3,6 @@
from contextlib import suppress
from functools import partial
import logging
-from typing import Generic
import broadlink as blk
from broadlink.exceptions import (
@@ -13,7 +12,6 @@ from broadlink.exceptions import (
ConnectionClosedError,
NetworkTimeoutError,
)
-from typing_extensions import TypeVar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -31,8 +29,6 @@ from homeassistant.helpers import device_registry as dr
from .const import DEFAULT_PORT, DOMAIN, DOMAINS_AND_TYPES
from .updater import BroadlinkUpdateManager, get_update_manager
-_ApiT = TypeVar("_ApiT", bound=blk.Device, default=blk.Device)
-
_LOGGER = logging.getLogger(__name__)
@@ -41,7 +37,7 @@ def get_domains(device_type: str) -> set[Platform]:
return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t}
-class BroadlinkDevice(Generic[_ApiT]):
+class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
"""Manages a Broadlink device."""
api: _ApiT
diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json
index 17c98f0182f..492023afd66 100644
--- a/homeassistant/components/broadlink/strings.json
+++ b/homeassistant/components/broadlink/strings.json
@@ -17,7 +17,7 @@
},
"reset": {
"title": "Unlock the device",
- "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Click on the device.\n3. Click `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock."
+ "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Select the device.\n3. Select `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock."
},
"unlock": {
"title": "Unlock the device (optional)",
diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py
index cc3b9dad464..9098440a5c4 100644
--- a/homeassistant/components/broadlink/switch.py
+++ b/homeassistant/components/broadlink/switch.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py
index f1455f5a541..8e0a521e182 100644
--- a/homeassistant/components/broadlink/updater.py
+++ b/homeassistant/components/broadlink/updater.py
@@ -5,11 +5,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
import logging
-from typing import TYPE_CHECKING, Any, Generic
+from typing import TYPE_CHECKING, Any, Generic, TypeVar
import broadlink as blk
from broadlink.exceptions import AuthorizationError, BroadlinkException
-from typing_extensions import TypeVar
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py
index d9130b96300..f6b3f456056 100644
--- a/homeassistant/components/brother/config_flow.py
+++ b/homeassistant/components/brother/config_flow.py
@@ -7,12 +7,12 @@ from typing import Any
from brother import Brother, SnmpError, UnsupportedModelError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.network import is_host_valid
from .const import DOMAIN, PRINTER_TYPES
@@ -83,7 +83,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.host = discovery_info.host
diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py
index cbd06381578..84450573989 100644
--- a/homeassistant/components/bt_home_hub_5/device_tracker.py
+++ b/homeassistant/components/bt_home_hub_5/device_tracker.py
@@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py
index 29f60bd317f..57ceb01700d 100644
--- a/homeassistant/components/bt_smarthub/device_tracker.py
+++ b/homeassistant/components/bt_smarthub/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py
index 24fdddf2cc7..524365c1183 100644
--- a/homeassistant/components/bthome/config_flow.py
+++ b/homeassistant/components/bthome/config_flow.py
@@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry()
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py
index d60089a9bf5..6d194714c64 100644
--- a/homeassistant/components/bthome/device_trigger.py
+++ b/homeassistant/components/bthome/device_trigger.py
@@ -42,6 +42,7 @@ EVENT_TYPES_BY_EVENT_CLASS = {
"long_press",
"long_double_press",
"long_triple_press",
+ "hold_press",
},
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
}
diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py
index 128d1e8388f..a6ee79f4e05 100644
--- a/homeassistant/components/bthome/event.py
+++ b/homeassistant/components/bthome/event.py
@@ -36,6 +36,7 @@ DESCRIPTIONS_BY_EVENT_CLASS = {
"long_press",
"long_double_press",
"long_triple_press",
+ "hold_press",
],
device_class=EventDeviceClass.BUTTON,
),
diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py
index be5e156e99c..1c41d5553da 100644
--- a/homeassistant/components/bthome/logbook.py
+++ b/homeassistant/components/bthome/logbook.py
@@ -26,7 +26,7 @@ def async_describe_events(
"""Describe bthome logbook event."""
data = event.data
device = dev_reg.async_get(data["device_id"])
- name = device and device.name or f'BTHome {data["address"]}'
+ name = (device and device.name) or f"BTHome {data['address']}"
if properties := data["event_properties"]:
message = f"{data['event_class']} {data['event_type']}: {properties}"
else:
diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json
index ad06f648d14..c8577113804 100644
--- a/homeassistant/components/bthome/manifest.json
+++ b/homeassistant/components/bthome/manifest.json
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
- "requirements": ["bthome-ble==3.9.1"]
+ "requirements": ["bthome-ble==3.12.3"]
}
diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py
index 417df9f5068..e46cbbea700 100644
--- a/homeassistant/components/bthome/sensor.py
+++ b/homeassistant/components/bthome/sensor.py
@@ -67,6 +67,16 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ # Conductivity (µS/cm)
+ (
+ BTHomeSensorDeviceClass.CONDUCTIVITY,
+ Units.CONDUCTIVITY,
+ ): SensorEntityDescription(
+ key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
+ device_class=SensorDeviceClass.CONDUCTIVITY,
+ native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
# Count (-)
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
key=str(BTHomeSensorDeviceClass.COUNT),
@@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
+ # Directions (°)
+ (BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription(
+ key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}",
+ native_unit_of_measurement=DEGREE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
# Distance (mm)
(
BTHomeSensorDeviceClass.DISTANCE,
@@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
+ # Precipitation (mm)
+ (
+ BTHomeExtendedSensorDeviceClass.PRECIPITATION,
+ Units.LENGTH_MILLIMETERS,
+ ): SensorEntityDescription(
+ key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}",
+ device_class=SensorDeviceClass.PRECIPITATION,
+ native_unit_of_measurement=UnitOfLength.MILLIMETERS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
# Pressure (mbar)
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
@@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.TOTAL,
),
- # Conductivity (µS/cm)
- (
- BTHomeSensorDeviceClass.CONDUCTIVITY,
- Units.CONDUCTIVITY,
- ): SensorEntityDescription(
- key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
- device_class=SensorDeviceClass.CONDUCTIVITY,
- native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
- state_class=SensorStateClass.MEASUREMENT,
- ),
}
diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json
index c64028229b3..daf969ba80f 100644
--- a/homeassistant/components/bthome/strings.json
+++ b/homeassistant/components/bthome/strings.json
@@ -37,6 +37,7 @@
"long_press": "Long Press",
"long_double_press": "Long Double Press",
"long_triple_press": "Long Triple Press",
+ "hold_press": "Hold Press",
"rotate_right": "Rotate Right",
"rotate_left": "Rotate Left"
},
@@ -56,7 +57,8 @@
"triple_press": "Triple press",
"long_press": "Long press",
"long_double_press": "Long double press",
- "long_triple_press": "Long triple press"
+ "long_triple_press": "Long triple press",
+ "hold_press": "Hold press"
}
}
}
diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py
index 45ad9028eb0..12f292036df 100644
--- a/homeassistant/components/buienradar/config_flow.py
+++ b/homeassistant/components/buienradar/config_flow.py
@@ -10,8 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import callback
-from homeassistant.helpers import selector
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaFlowFormStep,
diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py
index 14dc09ca33e..c6b90945329 100644
--- a/homeassistant/components/button/__init__.py
+++ b/homeassistant/components/button/__init__.py
@@ -7,7 +7,7 @@ from enum import StrEnum
import logging
from typing import final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py
index f4db7b619f8..30c0cc36835 100644
--- a/homeassistant/components/button/device_action.py
+++ b/homeassistant/components/button/device_action.py
@@ -13,8 +13,7 @@ from homeassistant.const import (
CONF_TYPE,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import DOMAIN, SERVICE_PRESS
diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py
index fb53947a723..c2bf1b2dce1 100644
--- a/homeassistant/components/caldav/calendar.py
+++ b/homeassistant/components/caldav/calendar.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py
index eb09e3f5452..c6bbd15bdff 100644
--- a/homeassistant/components/caldav/coordinator.py
+++ b/homeassistant/components/caldav/coordinator.py
@@ -186,12 +186,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
pattern = re.compile(search)
return (
- hasattr(vevent, "summary")
- and pattern.match(vevent.summary.value)
- or hasattr(vevent, "location")
- and pattern.match(vevent.location.value)
- or hasattr(vevent, "description")
- and pattern.match(vevent.description.value)
+ (hasattr(vevent, "summary") and pattern.match(vevent.summary.value))
+ or (hasattr(vevent, "location") and pattern.match(vevent.location.value))
+ or (
+ hasattr(vevent, "description")
+ and pattern.match(vevent.description.value)
+ )
)
@staticmethod
diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py
index 6f5a92feac0..fb0ad66c652 100644
--- a/homeassistant/components/cambridge_audio/config_flow.py
+++ b/homeassistant/components/cambridge_audio/config_flow.py
@@ -6,7 +6,6 @@ from typing import Any
from aiostreammagic import StreamMagicClient
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
@@ -14,6 +13,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
@@ -30,7 +30,7 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
self.data: dict[str, Any] = {}
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.data[CONF_HOST] = host = discovery_info.host
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index 4d718433fca..aa5d766c874 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -18,7 +18,7 @@ from typing import Any, Final, final
from aiohttp import hdrs, web
import attr
-from propcache import cached_property, under_cached_property
+from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit, RTCIceServer
@@ -523,7 +523,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
- if type(features) is int: # noqa: E721
+ if type(features) is int:
new_features = CameraEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
@@ -1175,12 +1175,17 @@ async def async_handle_snapshot_service(
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
)
- async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
- image = (
- await _async_get_stream_image(camera, wait_for_next_keyframe=True)
- if camera.use_stream_for_stills
- else await camera.async_camera_image()
- )
+ try:
+ async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
+ image = (
+ await _async_get_stream_image(camera, wait_for_next_keyframe=True)
+ if camera.use_stream_for_stills
+ else await camera.async_camera_image()
+ )
+ except TimeoutError as err:
+ raise HomeAssistantError(
+ f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds"
+ ) from err
if image is None:
return
@@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service(
try:
await hass.async_add_executor_job(_write_image, snapshot_file, image)
except OSError as err:
- _LOGGER.error("Can't write image to file: %s", err)
+ raise HomeAssistantError(f"Can't write image to file: {err}") from err
async def async_handle_play_stream_service(
diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py
index f879c308a88..b0e59e49a6f 100644
--- a/homeassistant/components/canary/__init__.py
+++ b/homeassistant/components/canary/__init__.py
@@ -11,22 +11,20 @@ from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_FFMPEG_ARGUMENTS,
- DATA_COORDINATOR,
- DATA_UNDO_UPDATE_LISTENER,
DEFAULT_FFMPEG_ARGUMENTS,
DEFAULT_TIMEOUT,
DOMAIN,
)
-from .coordinator import CanaryDataUpdateCoordinator
+from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
_LOGGER: Final = logging.getLogger(__name__)
@@ -59,8 +57,6 @@ PLATFORMS: Final[list[Platform]] = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Canary integration."""
- hass.data.setdefault(DOMAIN, {})
-
if hass.config_entries.async_entries(DOMAIN):
return True
@@ -90,7 +86,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool:
"""Set up Canary from a config entry."""
if not entry.options:
options = {
@@ -107,38 +103,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Unable to connect to Canary service: %s", str(error))
raise ConfigEntryNotReady from error
- coordinator = CanaryDataUpdateCoordinator(hass, api=canary_api)
+ coordinator = CanaryDataUpdateCoordinator(hass, entry, api=canary_api)
await coordinator.async_config_entry_first_refresh()
- undo_listener = entry.add_update_listener(_async_update_listener)
+ entry.async_on_unload(entry.add_update_listener(_async_update_listener))
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_COORDINATOR: coordinator,
- DATA_UNDO_UPDATE_LISTENER: undo_listener,
- }
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
- if unload_ok:
- hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]()
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(hass: HomeAssistant, entry: CanaryConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
-def _get_canary_api_instance(entry: ConfigEntry) -> Api:
+def _get_canary_api_instance(entry: CanaryConfigEntry) -> Api:
"""Initialize a new instance of CanaryApi."""
return Api(
entry.data[CONF_USERNAME],
diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py
index 69600e4bbc7..443944da8c3 100644
--- a/homeassistant/components/canary/alarm_control_panel.py
+++ b/homeassistant/components/canary/alarm_control_panel.py
@@ -12,24 +12,20 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import CanaryDataUpdateCoordinator
+from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: CanaryConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Canary alarm control panels based on a config entry."""
- coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
alarms = [
CanaryAlarm(coordinator, location)
for location_id, location in coordinator.data["locations"].items()
diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py
index a56d1ebc3de..8f4a01c9968 100644
--- a/homeassistant/components/canary/camera.py
+++ b/homeassistant/components/canary/camera.py
@@ -18,7 +18,6 @@ from homeassistant.components.camera import (
Camera,
)
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
@@ -27,14 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
-from .const import (
- CONF_FFMPEG_ARGUMENTS,
- DATA_COORDINATOR,
- DEFAULT_FFMPEG_ARGUMENTS,
- DOMAIN,
- MANUFACTURER,
-)
-from .coordinator import CanaryDataUpdateCoordinator
+from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DOMAIN, MANUFACTURER
+from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
FORCE_CAMERA_REFRESH_INTERVAL: Final = timedelta(minutes=15)
@@ -54,13 +47,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: CanaryConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Canary sensors based on a config entry."""
- coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
ffmpeg_arguments: str = entry.options.get(
CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
)
diff --git a/homeassistant/components/canary/const.py b/homeassistant/components/canary/const.py
index 210da35c7c1..9b9229c3ac3 100644
--- a/homeassistant/components/canary/const.py
+++ b/homeassistant/components/canary/const.py
@@ -9,10 +9,6 @@ MANUFACTURER: Final = "Canary Connect, Inc"
# Configuration
CONF_FFMPEG_ARGUMENTS: Final = "ffmpeg_arguments"
-# Data
-DATA_COORDINATOR: Final = "coordinator"
-DATA_UNDO_UPDATE_LISTENER: Final = "undo_update_listener"
-
# Defaults
DEFAULT_FFMPEG_ARGUMENTS: Final = "-pred 1"
DEFAULT_TIMEOUT: Final = 10
diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py
index d58d1da0f79..7c90074f81a 100644
--- a/homeassistant/components/canary/coordinator.py
+++ b/homeassistant/components/canary/coordinator.py
@@ -11,6 +11,7 @@ from canary.api import Api
from canary.model import Location, Reading
from requests.exceptions import ConnectTimeout, HTTPError
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -20,10 +21,15 @@ from .model import CanaryData
_LOGGER = logging.getLogger(__name__)
+type CanaryConfigEntry = ConfigEntry[CanaryDataUpdateCoordinator]
+
+
class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]):
"""Class to manage fetching Canary data."""
- def __init__(self, hass: HomeAssistant, *, api: Api) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry: CanaryConfigEntry, *, api: Api
+ ) -> None:
"""Initialize global Canary data updater."""
self.canary = api
update_interval = timedelta(seconds=30)
@@ -31,6 +37,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]):
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=update_interval,
)
diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py
index 9aab4698bf3..22f3eada2cb 100644
--- a/homeassistant/components/canary/sensor.py
+++ b/homeassistant/components/canary/sensor.py
@@ -7,7 +7,6 @@ from typing import Final
from canary.model import Device, Location, SensorType
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -18,8 +17,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER
-from .coordinator import CanaryDataUpdateCoordinator
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
type SensorTypeItem = tuple[
str, str | None, str | None, SensorDeviceClass | None, list[str]
@@ -64,13 +63,11 @@ STATE_AIR_QUALITY_VERY_ABNORMAL: Final = "very_abnormal"
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: CanaryConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Canary sensors based on a config entry."""
- coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
sensors: list[CanarySensor] = []
for location in coordinator.data["locations"].values():
diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py
index 03a3f2ea1f8..034cf856023 100644
--- a/homeassistant/components/cast/config_flow.py
+++ b/homeassistant/components/cast/config_flow.py
@@ -6,7 +6,7 @@ from typing import Any
import voluptuous as vol
-from homeassistant.components import onboarding, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -16,6 +16,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
@@ -50,7 +51,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_config()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
await self.async_set_unique_id(DOMAIN)
diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py
index 228c69b65ec..8f4af197b8e 100644
--- a/homeassistant/components/cast/helpers.py
+++ b/homeassistant/components/cast/helpers.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import configparser
from dataclasses import dataclass
import logging
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlparse
import aiohttp
@@ -129,7 +129,7 @@ class ChromecastInfo:
class ChromeCastZeroconf:
"""Class to hold a zeroconf instance."""
- __zconf: zeroconf.HaZeroconf | None = None
+ __zconf: ClassVar[zeroconf.HaZeroconf | None] = None
@classmethod
def set_zeroconf(cls, zconf: zeroconf.HaZeroconf) -> None:
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
index 28db97a857d..3cc17fae43b 100644
--- a/homeassistant/components/cast/media_player.py
+++ b/homeassistant/components/cast/media_player.py
@@ -53,7 +53,7 @@ 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.network import NoURLAvailableError, get_url, is_hass_url
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from .const import (
diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py
index a35568047ad..eae5d095ce7 100644
--- a/homeassistant/components/ccm15/__init__.py
+++ b/homeassistant/components/ccm15/__init__.py
@@ -2,34 +2,30 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import CCM15Coordinator
+from .coordinator import CCM15ConfigEntry, CCM15Coordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: CCM15ConfigEntry) -> bool:
"""Set up Midea ccm15 AC Controller from a config entry."""
coordinator = CCM15Coordinator(
hass,
+ entry,
entry.data[CONF_HOST],
entry.data[CONF_PORT],
)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: CCM15ConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py
index 3db8c3e1016..099b91ec02c 100644
--- a/homeassistant/components/ccm15/climate.py
+++ b/homeassistant/components/ccm15/climate.py
@@ -17,7 +17,6 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@@ -25,18 +24,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN
-from .coordinator import CCM15Coordinator
+from .coordinator import CCM15ConfigEntry, CCM15Coordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CCM15ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up all climate."""
- coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
ac_data: CCM15DeviceState = coordinator.data
entities = [
diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py
index 0e49e0929e5..c059796045c 100644
--- a/homeassistant/components/ccm15/config_flow.py
+++ b/homeassistant/components/ccm15/config_flow.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_TIMEOUT, DOMAIN
diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py
index cd3b313f700..03a59aa3f24 100644
--- a/homeassistant/components/ccm15/coordinator.py
+++ b/homeassistant/components/ccm15/coordinator.py
@@ -7,6 +7,7 @@ from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice
import httpx
from homeassistant.components.climate import HVACMode
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -19,15 +20,20 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+type CCM15ConfigEntry = ConfigEntry[CCM15Coordinator]
+
class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]):
"""Class to coordinate multiple CCM15Climate devices."""
- def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry: CCM15ConfigEntry, host: str, port: int
+ ) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=host,
update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL),
)
diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py
index 08cc239e972..c259e7f35c9 100644
--- a/homeassistant/components/ccm15/diagnostics.py
+++ b/homeassistant/components/ccm15/diagnostics.py
@@ -4,18 +4,16 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import CCM15Coordinator
+from .coordinator import CCM15ConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: CCM15ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
return {
str(device_id): {
diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py
index 1f78f95c259..0477ebb111c 100644
--- a/homeassistant/components/cisco_ios/device_tracker.py
+++ b/homeassistant/components/cisco_ios/device_tracker.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
-import re
from pexpect import pxssh
import voluptuous as vol
@@ -15,7 +14,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -101,11 +100,11 @@ class CiscoDeviceScanner(DeviceScanner):
return False
- def _get_arp_data(self):
+ def _get_arp_data(self) -> str | None:
"""Open connection to the router and get arp entries."""
try:
- cisco_ssh = pxssh.pxssh()
+ cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8")
cisco_ssh.login(
self.host,
self.username,
@@ -115,12 +114,11 @@ class CiscoDeviceScanner(DeviceScanner):
)
# Find the hostname
- initial_line = cisco_ssh.before.decode("utf-8").splitlines()
+ initial_line = (cisco_ssh.before or "").splitlines()
router_hostname = initial_line[len(initial_line) - 1]
router_hostname += "#"
# Set the discovered hostname as prompt
- regex_expression = f"(?i)^{router_hostname}".encode()
- cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE)
+ cisco_ssh.PROMPT = f"(?i)^{router_hostname}"
# Allow full arp table to print at once
cisco_ssh.sendline("terminal length 0")
cisco_ssh.prompt(1)
@@ -128,13 +126,11 @@ class CiscoDeviceScanner(DeviceScanner):
cisco_ssh.sendline("show ip arp")
cisco_ssh.prompt(1)
- devices_result = cisco_ssh.before
-
- return devices_result.decode("utf-8")
except pxssh.ExceptionPxssh as px_e:
_LOGGER.error("Failed to login via pxssh: %s", px_e)
+ return None
- return None
+ return cisco_ssh.before
def _parse_cisco_mac_address(cisco_hardware_addr):
diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json
index ba0678c167f..ddfb96a70ae 100644
--- a/homeassistant/components/cisco_ios/manifest.json
+++ b/homeassistant/components/cisco_ios/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
"quality_scale": "legacy",
- "requirements": ["pexpect==4.6.0"]
+ "requirements": ["pexpect==4.9.0"]
}
diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py
index 2c7398ae172..78bbcc9edbc 100644
--- a/homeassistant/components/cisco_mobility_express/device_tracker.py
+++ b/homeassistant/components/cisco_mobility_express/device_tracker.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py
index 74d033c62d4..2f76ed8f65a 100644
--- a/homeassistant/components/cisco_webex_teams/notify.py
+++ b/homeassistant/components/cisco_webex_teams/notify.py
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py
index 5e4da231eef..e08b651dd70 100644
--- a/homeassistant/components/citybikes/sensor.py
+++ b/homeassistant/components/citybikes/sensor.py
@@ -28,13 +28,13 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
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 location
+from homeassistant.util import location as location_util
from homeassistant.util.unit_conversion import DistanceConverter
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
@@ -193,7 +193,7 @@ async def async_setup_platform(
devices = []
for station in network.stations:
- dist = location.distance(
+ dist = location_util.distance(
latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE]
)
station_id = station[ATTR_ID]
@@ -236,7 +236,7 @@ class CityBikesNetworks:
for network in self.networks:
network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
- dist = location.distance(
+ dist = location_util.distance(
latitude, longitude, network_latitude, network_longitude
)
if minimum_dist is None or dist < minimum_dist:
diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py
index 233ffc840c0..04c1305cb13 100644
--- a/homeassistant/components/clementine/media_player.py
+++ b/homeassistant/components/clementine/media_player.py
@@ -17,7 +17,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py
index c8d96d48faf..9a5a5160ada 100644
--- a/homeassistant/components/clickatell/notify.py
+++ b/homeassistant/components/clickatell/notify.py
@@ -15,7 +15,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py
index d00d7b413cc..53f16875d6f 100644
--- a/homeassistant/components/clicksend/notify.py
+++ b/homeassistant/components/clicksend/notify.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
CONTENT_TYPE_JSON,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py
index 6b5f2040448..5a08ccd7988 100644
--- a/homeassistant/components/clicksend_tts/notify.py
+++ b/homeassistant/components/clicksend_tts/notify.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONTENT_TYPE_JSON,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py
index ca85979f19a..3ea0f887e76 100644
--- a/homeassistant/components/climate/__init__.py
+++ b/homeassistant/components/climate/__init__.py
@@ -7,7 +7,7 @@ import functools as ft
import logging
from typing import Any, Literal, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -69,6 +69,7 @@ from .const import ( # noqa: F401
FAN_TOP,
HVAC_MODES,
INTENT_GET_TEMPERATURE,
+ INTENT_SET_TEMPERATURE,
PRESET_ACTIVITY,
PRESET_AWAY,
PRESET_BOOST,
diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py
index 111401a2251..d347ccbbb29 100644
--- a/homeassistant/components/climate/const.py
+++ b/homeassistant/components/climate/const.py
@@ -127,6 +127,7 @@ DEFAULT_MAX_HUMIDITY = 99
DOMAIN = "climate"
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
+INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
SERVICE_SET_AUX_HEAT = "set_aux_heat"
SERVICE_SET_FAN_MODE = "set_fan_mode"
diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py
index 84f166b752e..c9d098d7be6 100644
--- a/homeassistant/components/climate/device_action.py
+++ b/homeassistant/components/climate/device_action.py
@@ -17,8 +17,7 @@ from homeassistant.const import (
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_capability, get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py
index 9a8dfdda4ec..9837a326188 100644
--- a/homeassistant/components/climate/intent.py
+++ b/homeassistant/components/climate/intent.py
@@ -4,15 +4,24 @@ from __future__ import annotations
import voluptuous as vol
+from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import intent
+from homeassistant.helpers import config_validation as cv, intent
-from . import DOMAIN, INTENT_GET_TEMPERATURE
+from . import (
+ ATTR_TEMPERATURE,
+ DOMAIN,
+ INTENT_GET_TEMPERATURE,
+ INTENT_SET_TEMPERATURE,
+ SERVICE_SET_TEMPERATURE,
+ ClimateEntityFeature,
+)
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the climate intents."""
intent.async_register(hass, GetTemperatureIntent())
+ intent.async_register(hass, SetTemperatureIntent())
class GetTemperatureIntent(intent.IntentHandler):
@@ -52,3 +61,84 @@ class GetTemperatureIntent(intent.IntentHandler):
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
+
+
+class SetTemperatureIntent(intent.IntentHandler):
+ """Handle SetTemperature intents."""
+
+ intent_type = INTENT_SET_TEMPERATURE
+ description = "Sets the target temperature of a climate device or entity"
+ slot_schema = {
+ vol.Required("temperature"): vol.Coerce(float),
+ vol.Optional("area"): intent.non_empty_string,
+ vol.Optional("name"): intent.non_empty_string,
+ vol.Optional("floor"): intent.non_empty_string,
+ vol.Optional("preferred_area_id"): cv.string,
+ vol.Optional("preferred_floor_id"): cv.string,
+ }
+ platforms = {DOMAIN}
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Handle the intent."""
+ hass = intent_obj.hass
+ slots = self.async_validate_slots(intent_obj.slots)
+
+ temperature: float = slots["temperature"]["value"]
+
+ name: str | None = None
+ if "name" in slots:
+ name = slots["name"]["value"]
+
+ area_name: str | None = None
+ if "area" in slots:
+ area_name = slots["area"]["value"]
+
+ floor_name: str | None = None
+ if "floor" in slots:
+ floor_name = slots["floor"]["value"]
+
+ match_constraints = intent.MatchTargetsConstraints(
+ name=name,
+ area_name=area_name,
+ floor_name=floor_name,
+ domains=[DOMAIN],
+ assistant=intent_obj.assistant,
+ features=ClimateEntityFeature.TARGET_TEMPERATURE,
+ single_target=True,
+ )
+ match_preferences = intent.MatchTargetsPreferences(
+ area_id=slots.get("preferred_area_id", {}).get("value"),
+ floor_id=slots.get("preferred_floor_id", {}).get("value"),
+ )
+ match_result = intent.async_match_targets(
+ hass, match_constraints, match_preferences
+ )
+ if not match_result.is_match:
+ raise intent.MatchFailedError(
+ result=match_result, constraints=match_constraints
+ )
+
+ assert match_result.states
+ climate_state = match_result.states[0]
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_TEMPERATURE,
+ service_data={ATTR_TEMPERATURE: temperature},
+ target={ATTR_ENTITY_ID: climate_state.entity_id},
+ blocking=True,
+ )
+
+ response = intent_obj.create_response()
+ response.response_type = intent.IntentResponseType.ACTION_DONE
+ response.async_set_results(
+ success_results=[
+ intent.IntentResponseTarget(
+ type=intent.IntentResponseTargetType.ENTITY,
+ name=climate_state.name,
+ id=climate_state.entity_id,
+ )
+ ]
+ )
+ response.async_set_states(matched_states=[climate_state])
+ return response
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 80b00237fd3..55ffedd2781 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -50,7 +50,6 @@ from .const import (
CONF_ACCOUNTS_SERVER,
CONF_ACME_SERVER,
CONF_ALEXA,
- CONF_ALEXA_SERVER,
CONF_ALIASES,
CONF_CLOUDHOOK_SERVER,
CONF_COGNITO_CLIENT_ID,
@@ -128,7 +127,6 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str,
- vol.Optional(CONF_ALEXA_SERVER): str,
vol.Optional(CONF_CLOUDHOOK_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str,
vol.Optional(CONF_REMOTESTATE_SERVER): str,
diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py
index b67c1afad71..851d658f8e0 100644
--- a/homeassistant/components/cloud/account_link.py
+++ b/homeassistant/components/cloud/account_link.py
@@ -65,7 +65,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]:
services: list[dict[str, Any]]
if DATA_SERVICES in hass.data:
services = hass.data[DATA_SERVICES]
- return services # noqa: RET504
+ return services
try:
services = await account_link.async_fetch_available_services(
diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py
index c97e5bdc0a2..0e3736d9da8 100644
--- a/homeassistant/components/cloud/assist_pipeline.py
+++ b/homeassistant/components/cloud/assist_pipeline.py
@@ -14,7 +14,7 @@ from homeassistant.components.stt import DOMAIN as STT_DOMAIN
from homeassistant.components.tts import DOMAIN as TTS_DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from .const import (
DATA_PLATFORMS_SETUP,
diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py
index f94a3a0ff49..d42e846259c 100644
--- a/homeassistant/components/cloud/backup.py
+++ b/homeassistant/components/cloud/backup.py
@@ -8,9 +8,9 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
import random
-from typing import Any, Self
+from typing import Any
-from aiohttp import ClientError, ClientTimeout, StreamReader
+from aiohttp import ClientError, ClientTimeout
from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.cloud_api import (
async_files_delete_file,
@@ -21,6 +21,7 @@ from hass_nabucasa.cloud_api import (
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .client import CloudClient
@@ -78,36 +79,10 @@ def async_register_backup_agents_listener(
return unsub
-class ChunkAsyncStreamIterator:
- """Async iterator for chunked streams.
-
- Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields
- bytes instead of tuple[bytes, bool].
- """
-
- __slots__ = ("_stream",)
-
- def __init__(self, stream: StreamReader) -> None:
- """Initialize."""
- self._stream = stream
-
- def __aiter__(self) -> Self:
- """Iterate."""
- return self
-
- async def __anext__(self) -> bytes:
- """Yield next chunk."""
- rv = await self._stream.readchunk()
- if rv == (b"", False):
- raise StopAsyncIteration
- return rv[0]
-
-
class CloudBackupAgent(BackupAgent):
"""Cloud backup agent."""
- domain = DOMAIN
- name = DOMAIN
+ domain = name = unique_id = DOMAIN
def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
"""Initialize the cloud backup sync agent."""
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index cff71bacebc..3883f19d1b7 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -76,7 +76,6 @@ CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
-CONF_ALEXA_SERVER = "alexa_server"
CONF_CLOUDHOOK_SERVER = "cloudhook_server"
CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server"
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index 473f553593a..b1a845ef8b0 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.components.system_health import get_info as get_system_health_info
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
hass.http.register_view(CloudRegisterView)
hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView)
+ hass.http.register_view(DownloadSupportPackageView)
_CLOUD_ERRORS.update(
{
@@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
return self.json_message("ok")
+class DownloadSupportPackageView(HomeAssistantView):
+ """Download support package view."""
+
+ url = "/api/cloud/support_package"
+ name = "api:cloud:support_package"
+
+ def _generate_markdown(
+ self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
+ ) -> str:
+ def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
+ if len(domain_info) == 0:
+ return "No information available\n"
+
+ markdown = ""
+ first = True
+ for key, value in domain_info.items():
+ markdown += f"{key} | {value}\n"
+ if first:
+ markdown += "--- | ---\n"
+ first = False
+ return markdown + "\n"
+
+ markdown = "## System Information\n\n"
+ markdown += get_domain_table_markdown(hass_info)
+
+ for domain, domain_info in domains_info.items():
+ domain_info_md = get_domain_table_markdown(domain_info)
+ markdown += (
+ f"{domain}
\n\n"
+ f"{domain_info_md}"
+ " \n\n"
+ )
+
+ return markdown
+
+ async def get(self, request: web.Request) -> web.Response:
+ """Download support package file."""
+
+ hass = request.app[KEY_HASS]
+ domain_health = await get_system_health_info(hass)
+
+ hass_info = domain_health.pop("homeassistant", {})
+ markdown = self._generate_markdown(hass_info, domain_health)
+
+ return web.Response(
+ body=markdown,
+ content_type="text/markdown",
+ headers={
+ "Content-Disposition": 'attachment; filename="support_package.md"'
+ },
+ )
+
+
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
@websocket_api.async_response
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 7ee8cf46b86..0f415b1738a 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
- "requirements": ["hass-nabucasa==0.87.0"],
+ "requirements": ["hass-nabucasa==0.88.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py
index 4dbee10fbaf..645ff4f9e75 100644
--- a/homeassistant/components/cloud/tts.py
+++ b/homeassistant/components/cloud/tts.py
@@ -22,7 +22,7 @@ from homeassistant.components.tts import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant, async_get_hass, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py
index bd27be71d18..f8fbac396a6 100644
--- a/homeassistant/components/cloudflare/__init__.py
+++ b/homeassistant/components/cloudflare/__init__.py
@@ -74,9 +74,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async_track_time_interval(hass, update_records, update_interval)
)
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = {}
-
hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service)
return True
@@ -84,7 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Cloudflare config entry."""
- hass.data[DOMAIN].pop(entry.entry_id)
return True
diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py
index d55e9ca8f0b..a1f303809d0 100644
--- a/homeassistant/components/cmus/media_player.py
+++ b/homeassistant/components/cmus/media_player.py
@@ -17,7 +17,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py
index 0d357cce199..530496811d9 100644
--- a/homeassistant/components/co2signal/config_flow.py
+++ b/homeassistant/components/co2signal/config_flow.py
@@ -20,8 +20,8 @@ from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
)
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py
index f5fd8fa1dc3..a29154d9c1b 100644
--- a/homeassistant/components/coinbase/__init__.py
+++ b/homeassistant/components/coinbase/__init__.py
@@ -37,7 +37,6 @@ from .const import (
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES,
- DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@@ -45,33 +44,29 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+type CoinbaseConfigEntry = ConfigEntry[CoinbaseData]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> bool:
"""Set up Coinbase from a config entry."""
instance = await hass.async_add_executor_job(create_and_update_instance, entry)
entry.async_on_unload(entry.add_update_listener(update_listener))
- hass.data.setdefault(DOMAIN, {})
-
- hass.data[DOMAIN][entry.entry_id] = instance
+ entry.runtime_data = instance
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
+def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance."""
if "organizations" not in entry.data[CONF_API_KEY]:
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
@@ -87,7 +82,9 @@ def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
return instance
-async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def update_listener(
+ hass: HomeAssistant, config_entry: CoinbaseConfigEntry
+) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
@@ -101,7 +98,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
if (
"xe" in entity.unique_id
and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, [])
- or "wallet" in entity.unique_id
+ ) or (
+ "wallet" in entity.unique_id
and currency not in config_entry.options.get(CONF_CURRENCIES, [])
):
registry.async_remove(entity.entity_id)
diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py
index 8b7b4b9e313..3234ec29679 100644
--- a/homeassistant/components/coinbase/config_flow.py
+++ b/homeassistant/components/coinbase/config_flow.py
@@ -11,18 +11,13 @@ from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
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 homeassistant.helpers import config_validation as cv
-from . import get_accounts
+from . import CoinbaseConfigEntry, get_accounts
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_CURRENCY,
@@ -83,10 +78,12 @@ async def validate_api(hass: HomeAssistant, data):
return {"title": user, "api_version": api_version}
-async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
+async def validate_options(
+ hass: HomeAssistant, config_entry: CoinbaseConfigEntry, options
+):
"""Validate the requested resources are provided by API."""
- client = hass.data[DOMAIN][config_entry.entry_id].client
+ client = config_entry.runtime_data.client
accounts = await hass.async_add_executor_job(
get_accounts, client, config_entry.data.get("api_version", "v2")
@@ -155,7 +152,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: CoinbaseConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
diff --git a/homeassistant/components/coinbase/diagnostics.py b/homeassistant/components/coinbase/diagnostics.py
index 674ce9dca28..f391b1a14f5 100644
--- a/homeassistant/components/coinbase/diagnostics.py
+++ b/homeassistant/components/coinbase/diagnostics.py
@@ -3,12 +3,11 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_ID
from homeassistant.core import HomeAssistant
-from . import CoinbaseData
-from .const import API_ACCOUNT_AMOUNT, API_RESOURCE_PATH, CONF_TITLE, DOMAIN
+from . import CoinbaseConfigEntry
+from .const import API_ACCOUNT_AMOUNT, API_RESOURCE_PATH, CONF_TITLE
TO_REDACT = {
API_ACCOUNT_AMOUNT,
@@ -21,15 +20,13 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: CoinbaseConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- instance: CoinbaseData = hass.data[DOMAIN][entry.entry_id]
-
return async_redact_data(
{
"entry": entry.as_dict(),
- "accounts": instance.accounts,
+ "accounts": entry.runtime_data.accounts,
},
TO_REDACT,
)
diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py
index d3f3c81fb0c..37509160247 100644
--- a/homeassistant/components/coinbase/sensor.py
+++ b/homeassistant/components/coinbase/sensor.py
@@ -5,12 +5,11 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import SensorEntity, SensorStateClass
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import CoinbaseData
+from . import CoinbaseConfigEntry, CoinbaseData
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
@@ -45,11 +44,11 @@ ATTRIBUTION = "Data provided by coinbase.com"
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CoinbaseConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Coinbase sensor platform."""
- instance: CoinbaseData = hass.data[DOMAIN][config_entry.entry_id]
+ instance = config_entry.runtime_data
entities: list[SensorEntity] = []
diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py
index 81cd55564b9..775adb6a7d5 100644
--- a/homeassistant/components/color_extractor/__init__.py
+++ b/homeassistant/components/color_extractor/__init__.py
@@ -17,8 +17,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import aiohttp_client
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py
index b47255828e8..ac217eeb353 100644
--- a/homeassistant/components/comed_hourly_pricing/sensor.py
+++ b/homeassistant/components/comed_hourly_pricing/sensor.py
@@ -17,8 +17,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, CONF_OFFSET, CURRENCY_CENT, UnitOfEnergy
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py
index 12f28ef206d..60a4e40140d 100644
--- a/homeassistant/components/comelit/__init__.py
+++ b/homeassistant/components/comelit/__init__.py
@@ -2,12 +2,16 @@
from aiocomelit.const import BRIDGE
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
-from .const import DEFAULT_PORT, DOMAIN
-from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem
+from .const import DEFAULT_PORT
+from .coordinator import (
+ ComelitBaseCoordinator,
+ ComelitConfigEntry,
+ ComelitSerialBridge,
+ ComelitVedoSystem,
+)
BRIDGE_PLATFORMS = [
Platform.CLIMATE,
@@ -24,13 +28,14 @@ VEDO_PLATFORMS = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool:
"""Set up Comelit platform."""
coordinator: ComelitBaseCoordinator
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
coordinator = ComelitSerialBridge(
hass,
+ entry,
entry.data[CONF_HOST],
entry.data.get(CONF_PORT, DEFAULT_PORT),
entry.data[CONF_PIN],
@@ -39,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
else:
coordinator = ComelitVedoSystem(
hass,
+ entry,
entry.data[CONF_HOST],
entry.data.get(CONF_PORT, DEFAULT_PORT),
entry.data[CONF_PIN],
@@ -47,14 +53,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, platforms)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool:
"""Unload a config entry."""
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
@@ -62,10 +68,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
else:
platforms = VEDO_PLATFORMS
- coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
await coordinator.api.logout()
await coordinator.api.close()
- hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py
index b3bd6664bf8..f694c2b392b 100644
--- a/homeassistant/components/comelit/alarm_control_panel.py
+++ b/homeassistant/components/comelit/alarm_control_panel.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import cast
from aiocomelit.api import ComelitVedoAreaObject
from aiocomelit.const import ALARM_AREAS, AlarmAreaState
@@ -13,13 +14,11 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitVedoSystem
+from .coordinator import ComelitConfigEntry, ComelitVedoSystem
_LOGGER = logging.getLogger(__name__)
@@ -48,12 +47,12 @@ ALARM_AREA_ARMED_STATUS: dict[str, int] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Comelit VEDO system alarm control panel devices."""
- coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py
index 30b642584f8..fa51e0b1fda 100644
--- a/homeassistant/components/comelit/binary_sensor.py
+++ b/homeassistant/components/comelit/binary_sensor.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import cast
+
from aiocomelit import ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONES
@@ -9,23 +11,21 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitVedoSystem
+from .coordinator import ComelitConfigEntry, ComelitVedoSystem
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit VEDO presence sensors."""
- coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
async_add_entities(
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py
index 6dc7c7e26d9..1baa777bf99 100644
--- a/homeassistant/components/comelit/climate.py
+++ b/homeassistant/components/comelit/climate.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from enum import StrEnum
-from typing import Any
+from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
@@ -15,14 +15,12 @@ from homeassistant.components.climate import (
HVACMode,
UnitOfTemperature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitSerialBridge
+from .coordinator import ComelitConfigEntry, ComelitSerialBridge
class ClimaComelitMode(StrEnum):
@@ -72,12 +70,12 @@ MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit climates."""
- coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
async_add_entities(
ComelitClimateEntity(coordinator, device, config_entry.entry_id)
diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py
index 46fc13796a0..f29cc62136b 100644
--- a/homeassistant/components/comelit/config_flow.py
+++ b/homeassistant/components/comelit/config_flow.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py
index 807f389a6d3..fcb149b21d6 100644
--- a/homeassistant/components/comelit/coordinator.py
+++ b/homeassistant/components/comelit/coordinator.py
@@ -23,15 +23,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import _LOGGER, DOMAIN
+type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
+
class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Base coordinator for Comelit Devices."""
_hw_version: str
- config_entry: ConfigEntry
+ config_entry: ComelitConfigEntry
api: ComelitCommonApi
- def __init__(self, hass: HomeAssistant, device: str, host: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry: ComelitConfigEntry, device: str, host: str
+ ) -> None:
"""Initialize the scanner."""
self._device = device
@@ -40,13 +44,14 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass=hass,
logger=_LOGGER,
+ config_entry=entry,
name=f"{DOMAIN}-{host}-coordinator",
update_interval=timedelta(seconds=5),
)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
- config_entry_id=self.config_entry.entry_id,
- identifiers={(DOMAIN, self.config_entry.entry_id)},
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.entry_id)},
model=device,
name=f"{device} ({self._host})",
manufacturer="Comelit",
@@ -98,10 +103,17 @@ class ComelitSerialBridge(ComelitBaseCoordinator):
_hw_version = "20003101"
api: ComeliteSerialBridgeApi
- def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ComelitConfigEntry,
+ host: str,
+ port: int,
+ pin: int,
+ ) -> None:
"""Initialize the scanner."""
self.api = ComeliteSerialBridgeApi(host, port, pin)
- super().__init__(hass, BRIDGE, host)
+ super().__init__(hass, entry, BRIDGE, host)
async def _async_update_system_data(self) -> dict[str, Any]:
"""Specific method for updating data."""
@@ -114,10 +126,17 @@ class ComelitVedoSystem(ComelitBaseCoordinator):
_hw_version = "VEDO IP"
api: ComelitVedoApi
- def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ComelitConfigEntry,
+ host: str,
+ port: int,
+ pin: int,
+ ) -> None:
"""Initialize the scanner."""
self.api = ComelitVedoApi(host, port, pin)
- super().__init__(hass, VEDO, host)
+ super().__init__(hass, entry, VEDO, host)
async def _async_update_system_data(self) -> dict[str, Any]:
"""Specific method for updating data."""
diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py
index 5169217ebc5..abb84824621 100644
--- a/homeassistant/components/comelit/cover.py
+++ b/homeassistant/components/comelit/cover.py
@@ -2,30 +2,28 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitSerialBridge
+from .coordinator import ComelitConfigEntry, ComelitSerialBridge
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit covers."""
- coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
async_add_entities(
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
diff --git a/homeassistant/components/comelit/diagnostics.py b/homeassistant/components/comelit/diagnostics.py
index afa57831eae..547735f3879 100644
--- a/homeassistant/components/comelit/diagnostics.py
+++ b/homeassistant/components/comelit/diagnostics.py
@@ -12,22 +12,20 @@ from aiocomelit import (
from aiocomelit.const import BRIDGE
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PIN, CONF_TYPE
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import ComelitBaseCoordinator
+from .coordinator import ComelitConfigEntry
TO_REDACT = {CONF_PIN}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: ComelitConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
dev_list: list[dict[str, Any]] = []
dev_type_list: list[dict[int, Any]] = []
diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py
index e7857535c78..d8058074c16 100644
--- a/homeassistant/components/comelit/humidifier.py
+++ b/homeassistant/components/comelit/humidifier.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from enum import StrEnum
-from typing import Any
+from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
@@ -16,14 +16,13 @@ from homeassistant.components.humidifier import (
HumidifierEntity,
HumidifierEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import ComelitSerialBridge
+from .coordinator import ComelitConfigEntry, ComelitSerialBridge
class HumidifierComelitMode(StrEnum):
@@ -55,12 +54,12 @@ MODE_TO_ACTION: dict[str, HumidifierComelitCommand] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit humidifiers."""
- coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
entities: list[ComelitHumidifierEntity] = []
for device in coordinator.data[CLIMATE].values():
diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py
index bb5eb5fa160..9736c9ac2a0 100644
--- a/homeassistant/components/comelit/light.py
+++ b/homeassistant/components/comelit/light.py
@@ -2,29 +2,27 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON
from homeassistant.components.light import ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitSerialBridge
+from .coordinator import ComelitConfigEntry, ComelitSerialBridge
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit lights."""
- coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py
index a86d49d73e9..efb2418244e 100644
--- a/homeassistant/components/comelit/sensor.py
+++ b/homeassistant/components/comelit/sensor.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Final
+from typing import Final, cast
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState
@@ -12,15 +12,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitSerialBridge, ComelitVedoSystem
+from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
SENSOR_BRIDGE_TYPES: Final = (
SensorEntityDescription(
@@ -43,7 +41,7 @@ SENSOR_VEDO_TYPES: Final = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit sensors."""
@@ -56,12 +54,12 @@ async def async_setup_entry(
async def async_setup_bridge_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit Bridge sensors."""
- coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
entities: list[ComelitBridgeSensorEntity] = []
for device in coordinator.data[OTHER].values():
@@ -76,12 +74,12 @@ async def async_setup_bridge_entry(
async def async_setup_vedo_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit VEDO sensors."""
- coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
entities: list[ComelitVedoSensorEntity] = []
for device in coordinator.data[ALARM_ZONES].values():
diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py
index 68ba934adb6..26d3b81ebde 100644
--- a/homeassistant/components/comelit/switch.py
+++ b/homeassistant/components/comelit/switch.py
@@ -2,29 +2,27 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import ComelitSerialBridge
+from .coordinator import ComelitConfigEntry, ComelitSerialBridge
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ComelitConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit switches."""
- coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
entities: list[ComelitSwitchEntity] = []
entities.extend(
diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py
index 4e0671fd134..b28f7a748e1 100644
--- a/homeassistant/components/comfoconnect/__init__.py
+++ b/homeassistant/components/comfoconnect/__init__.py
@@ -14,8 +14,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py
index 6a15e37f3f1..fbe958e6d67 100644
--- a/homeassistant/components/comfoconnect/sensor.py
+++ b/homeassistant/components/comfoconnect/sensor.py
@@ -48,7 +48,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py
index 2440fcde76c..1832e83e7dd 100644
--- a/homeassistant/components/command_line/__init__.py
+++ b/homeassistant/components/command_line/__init__.py
@@ -52,8 +52,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py
index fae416e7fc2..e83339d2c18 100644
--- a/homeassistant/components/compensation/__init__.py
+++ b/homeassistant/components/compensation/__init__.py
@@ -42,7 +42,7 @@ def datapoints_greater_than_degree(value: dict) -> dict:
if len(value[CONF_DATAPOINTS]) <= value[CONF_DEGREE]:
raise vol.Invalid(
f"{CONF_DATAPOINTS} must have at least"
- f" {value[CONF_DEGREE]+1} {CONF_DATAPOINTS}"
+ f" {value[CONF_DEGREE] + 1} {CONF_DATAPOINTS}"
)
return value
diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json
index ac82938b97b..cdf4dd1aaa4 100644
--- a/homeassistant/components/compensation/manifest.json
+++ b/homeassistant/components/compensation/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
"quality_scale": "legacy",
- "requirements": ["numpy==2.2.0"]
+ "requirements": ["numpy==2.2.2"]
}
diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py
index 02453b56376..61cf2aebb31 100644
--- a/homeassistant/components/concord232/alarm_control_panel.py
+++ b/homeassistant/components/concord232/alarm_control_panel.py
@@ -18,7 +18,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.const import CONF_CODE, CONF_HOST, CONF_MODE, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py
index 2b86e72e63c..a60cf31a646 100644
--- a/homeassistant/components/concord232/binary_sensor.py
+++ b/homeassistant/components/concord232/binary_sensor.py
@@ -17,10 +17,10 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py
index c8cc9242ea4..b2a590928c1 100644
--- a/homeassistant/components/config/area_registry.py
+++ b/homeassistant/components/config/area_registry.py
@@ -41,10 +41,12 @@ def websocket_list_areas(
vol.Required("type"): "config/area_registry/create",
vol.Optional("aliases"): list,
vol.Optional("floor_id"): str,
+ vol.Optional("humidity_entity_id"): vol.Any(str, None),
vol.Optional("icon"): str,
vol.Optional("labels"): [str],
vol.Required("name"): str,
vol.Optional("picture"): vol.Any(str, None),
+ vol.Optional("temperature_entity_id"): vol.Any(str, None),
}
)
@websocket_api.require_admin
@@ -107,10 +109,12 @@ def websocket_delete_area(
vol.Optional("aliases"): list,
vol.Required("area_id"): str,
vol.Optional("floor_id"): vol.Any(str, None),
+ vol.Optional("humidity_entity_id"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("labels"): [str],
vol.Optional("name"): str,
vol.Optional("picture"): vol.Any(str, None),
+ vol.Optional("temperature_entity_id"): vol.Any(str, None),
}
)
@websocket_api.require_admin
diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py
index da50f7e93a1..52e3346002e 100644
--- a/homeassistant/components/config/config_entries.py
+++ b/homeassistant/components/config/config_entries.py
@@ -17,7 +17,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_a
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import DependencyError, Unauthorized
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.data_entry_flow import (
FlowManagerIndexView,
FlowManagerResourceView,
@@ -302,7 +302,8 @@ def config_entries_progress(
[
flw
for flw in hass.config_entries.flow.async_progress()
- if flw["context"]["source"] != config_entries.SOURCE_USER
+ if flw["context"]["source"]
+ not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER)
],
)
diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py
index 6f788b1c9f2..b40f533d1f8 100644
--- a/homeassistant/components/config/core.py
+++ b/homeassistant/components/config/core.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import async_update_suggested_units
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import check_config, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.util import location, unit_system
+from homeassistant.util import location as location_util, unit_system
@callback
@@ -99,7 +99,7 @@ async def websocket_detect_config(
) -> None:
"""Detect core config."""
session = async_get_clientsession(hass)
- location_info = await location.async_detect_location_info(session)
+ location_info = await location_util.async_detect_location_info(session)
info: dict[str, Any] = {}
diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py
index aed04943975..b987f249a33 100644
--- a/homeassistant/components/config/entity_registry.py
+++ b/homeassistant/components/config/entity_registry.py
@@ -279,9 +279,8 @@ def websocket_update_entity(
result: dict[str, Any] = {"entity_entry": entity_entry.extended_dict}
if "disabled_by" in changes and changes["disabled_by"] is None:
# Enabling an entity requires a config entry reload, or HA restart
- if (
- not (config_entry_id := entity_entry.config_entry_id)
- or (config_entry := hass.config_entries.async_get_entry(config_entry_id))
+ if not (config_entry_id := entity_entry.config_entry_id) or (
+ (config_entry := hass.config_entries.async_get_entry(config_entry_id))
and not config_entry.supports_unload
):
result["require_restart"] = True
diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py
index 8d0eb72a73b..df5771fe5bb 100644
--- a/homeassistant/components/control4/__init__.py
+++ b/homeassistant/components/control4/__init__.py
@@ -2,8 +2,10 @@
from __future__ import annotations
+from dataclasses import dataclass
import json
import logging
+from typing import Any
from aiohttp import client_exceptions
from pyControl4.account import C4Account
@@ -25,14 +27,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr
from .const import (
API_RETRY_TIMES,
- CONF_ACCOUNT,
- CONF_CONFIG_LISTENER,
CONF_CONTROLLER_UNIQUE_ID,
- CONF_DIRECTOR,
- CONF_DIRECTOR_ALL_ITEMS,
- CONF_DIRECTOR_MODEL,
- CONF_DIRECTOR_SW_VERSION,
- CONF_UI_CONFIGURATION,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
@@ -42,6 +37,23 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
+@dataclass
+class Control4RuntimeData:
+ """Control4 runtime data."""
+
+ account: C4Account
+ controller_unique_id: str
+ director: C4Director
+ director_all_items: list[dict[str, Any]]
+ director_model: str
+ director_sw_version: str
+ scan_interval: int
+ ui_configuration: dict[str, Any] | None
+
+
+type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
+
+
async def call_c4_api_retry(func, *func_args):
"""Call C4 API function and retry on failure."""
# Ruff doesn't understand this loop - the exception is always raised after the retries
@@ -54,10 +66,8 @@ async def call_c4_api_retry(func, *func_args):
raise ConfigEntryNotReady(exception) from exception
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool:
"""Set up Control4 from a config entry."""
- hass.data.setdefault(DOMAIN, {})
- entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {})
account_session = aiohttp_client.async_get_clientsession(hass)
config = entry.data
@@ -76,10 +86,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
exception,
)
return False
- entry_data[CONF_ACCOUNT] = account
- controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID]
- entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id
+ controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID]
director_token_dict = await call_c4_api_retry(
account.getDirectorBearerToken, controller_unique_id
@@ -89,15 +97,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
director = C4Director(
config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session
)
- entry_data[CONF_DIRECTOR] = director
controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"]
- entry_data[CONF_DIRECTOR_SW_VERSION] = await call_c4_api_retry(
+ director_sw_version = await call_c4_api_retry(
account.getControllerOSVersion, controller_href
)
_, model, mac_address = controller_unique_id.split("_", 3)
- entry_data[CONF_DIRECTOR_MODEL] = model.upper()
+ director_model = model.upper()
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
@@ -106,57 +113,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
connections={(dr.CONNECTION_NETWORK_MAC, mac_address)},
manufacturer="Control4",
name=controller_unique_id,
- model=entry_data[CONF_DIRECTOR_MODEL],
- sw_version=entry_data[CONF_DIRECTOR_SW_VERSION],
+ model=director_model,
+ sw_version=director_sw_version,
)
# Store all items found on controller for platforms to use
- director_all_items = await director.getAllItemInfo()
- director_all_items = json.loads(director_all_items)
- entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items
-
- # Check if OS version is 3 or higher to get UI configuration
- entry_data[CONF_UI_CONFIGURATION] = None
- if int(entry_data[CONF_DIRECTOR_SW_VERSION].split(".")[0]) >= 3:
- entry_data[CONF_UI_CONFIGURATION] = json.loads(
- await director.getUiConfiguration()
- )
-
- # Load options from config entry
- entry_data[CONF_SCAN_INTERVAL] = entry.options.get(
- CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
+ director_all_items: list[dict[str, Any]] = json.loads(
+ await director.getAllItemInfo()
)
- entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener)
+ # Check if OS version is 3 or higher to get UI configuration
+ ui_configuration: dict[str, Any] | None = None
+ if int(director_sw_version.split(".")[0]) >= 3:
+ ui_configuration = json.loads(await director.getUiConfiguration())
+
+ # Load options from config entry
+ scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
+
+ entry.runtime_data = Control4RuntimeData(
+ account=account,
+ controller_unique_id=controller_unique_id,
+ director=director,
+ director_all_items=director_all_items,
+ director_model=director_model,
+ director_sw_version=director_sw_version,
+ scan_interval=scan_interval,
+ ui_configuration=ui_configuration,
+ )
+
+ entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def update_listener(
+ hass: HomeAssistant, config_entry: Control4ConfigEntry
+) -> None:
"""Update when config_entry options update."""
_LOGGER.debug("Config entry was updated, rerunning setup")
await hass.config_entries.async_reload(config_entry.entry_id)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
- hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]()
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
- _LOGGER.debug("Unloaded entry for %s", entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str):
+async def get_items_of_category(
+ hass: HomeAssistant, entry: Control4ConfigEntry, category: str
+):
"""Return a list of all Control4 items with the specified category."""
- director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS]
return [
item
- for item in director_all_items
+ for item in entry.runtime_data.director_all_items
if "categories" in item and category in item["categories"]
]
diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py
index 19fae1ef7ca..3ca96ca4e52 100644
--- a/homeassistant/components/control4/config_flow.py
+++ b/homeassistant/components/control4/config_flow.py
@@ -11,12 +11,7 @@ from pyControl4.director import C4Director
from pyControl4.error_handling import NotFound, Unauthorized
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -28,6 +23,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.device_registry import format_mac
+from . import Control4ConfigEntry
from .const import (
CONF_CONTROLLER_UNIQUE_ID,
DEFAULT_SCAN_INTERVAL,
@@ -151,7 +147,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: Control4ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py
index 57074c00108..2fe9c42849b 100644
--- a/homeassistant/components/control4/const.py
+++ b/homeassistant/components/control4/const.py
@@ -7,14 +7,6 @@ MIN_SCAN_INTERVAL = 1
API_RETRY_TIMES = 5
-CONF_ACCOUNT = "account"
-CONF_DIRECTOR = "director"
-CONF_DIRECTOR_SW_VERSION = "director_sw_version"
-CONF_DIRECTOR_MODEL = "director_model"
-CONF_DIRECTOR_ALL_ITEMS = "director_all_items"
-CONF_UI_CONFIGURATION = "ui_configuration"
CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id"
-CONF_CONFIG_LISTENER = "config_listener"
-
CONTROL4_ENTITY_TYPE = 7
diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py
index 5e57237337c..a26c5f9f413 100644
--- a/homeassistant/components/control4/director_utils.py
+++ b/homeassistant/components/control4/director_utils.py
@@ -8,21 +8,21 @@ from pyControl4.account import C4Account
from pyControl4.director import C4Director
from pyControl4.error_handling import BadToken
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
-from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN
+from . import Control4ConfigEntry
+from .const import CONF_CONTROLLER_UNIQUE_ID
_LOGGER = logging.getLogger(__name__)
async def _update_variables_for_config_entry(
- hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str]
+ hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str]
) -> dict[int, dict[str, Any]]:
"""Retrieve data from the Control4 director."""
- director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR]
+ director = entry.runtime_data.director
data = await director.getAllItemVariableValue(variable_names)
result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict)
for item in data:
@@ -31,7 +31,7 @@ async def _update_variables_for_config_entry(
async def update_variables_for_config_entry(
- hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str]
+ hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str]
) -> dict[int, dict[str, Any]]:
"""Try to Retrieve data from the Control4 director for update_coordinator."""
try:
@@ -42,8 +42,8 @@ async def update_variables_for_config_entry(
return await _update_variables_for_config_entry(hass, entry, variable_names)
-async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry):
- """Store updated authentication and director tokens in hass.data."""
+async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry):
+ """Store updated authentication and director tokens in runtime_data."""
config = entry.data
account_session = aiohttp_client.async_get_clientsession(hass)
@@ -59,6 +59,5 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry):
)
_LOGGER.debug("Saving new tokens in hass data")
- entry_data = hass.data[DOMAIN][entry.entry_id]
- entry_data[CONF_ACCOUNT] = account
- entry_data[CONF_DIRECTOR] = director
+ entry.runtime_data.account = account
+ entry.runtime_data.director = director
diff --git a/homeassistant/components/control4/entity.py b/homeassistant/components/control4/entity.py
index fdb22e6578d..f7ca0e1fabc 100644
--- a/homeassistant/components/control4/entity.py
+++ b/homeassistant/components/control4/entity.py
@@ -10,7 +10,8 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
-from .const import CONF_CONTROLLER_UNIQUE_ID, DOMAIN
+from . import Control4RuntimeData
+from .const import DOMAIN
class Control4Entity(CoordinatorEntity[Any]):
@@ -18,7 +19,7 @@ class Control4Entity(CoordinatorEntity[Any]):
def __init__(
self,
- entry_data: dict,
+ runtime_data: Control4RuntimeData,
coordinator: DataUpdateCoordinator[Any],
name: str | None,
idx: int,
@@ -29,11 +30,11 @@ class Control4Entity(CoordinatorEntity[Any]):
) -> None:
"""Initialize a Control4 entity."""
super().__init__(coordinator)
- self.entry_data = entry_data
+ self.runtime_data = runtime_data
self._attr_name = name
self._attr_unique_id = str(idx)
self._idx = idx
- self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID]
+ self._controller_unique_id = runtime_data.controller_unique_id
self._device_name = device_name
self._device_manufacturer = device_manufacturer
self._device_model = device_model
diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py
index 927f4643619..cedfbeb49c3 100644
--- a/homeassistant/components/control4/light.py
+++ b/homeassistant/components/control4/light.py
@@ -17,14 +17,12 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from . import get_items_of_category
-from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN
+from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
+from .const import CONTROL4_ENTITY_TYPE
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity
@@ -36,15 +34,13 @@ CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: Control4ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Control4 lights from a config entry."""
- entry_data = hass.data[DOMAIN][entry.entry_id]
- scan_interval = entry_data[CONF_SCAN_INTERVAL]
- _LOGGER.debug(
- "Scan interval = %s",
- scan_interval,
- )
+ runtime_data = entry.runtime_data
+ _LOGGER.debug("Scan interval = %s", runtime_data.scan_interval)
async def async_update_data_non_dimmer() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director for non-dimmer lights."""
@@ -69,14 +65,14 @@ async def async_setup_entry(
_LOGGER,
name="light",
update_method=async_update_data_non_dimmer,
- update_interval=timedelta(seconds=scan_interval),
+ update_interval=timedelta(seconds=runtime_data.scan_interval),
)
dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="light",
update_method=async_update_data_dimmer,
- update_interval=timedelta(seconds=scan_interval),
+ update_interval=timedelta(seconds=runtime_data.scan_interval),
)
# Fetch initial data so we have data when entities subscribe
@@ -118,7 +114,7 @@ async def async_setup_entry(
item_is_dimmer = False
item_coordinator = non_dimmer_coordinator
else:
- director = entry_data[CONF_DIRECTOR]
+ director = runtime_data.director
item_variables = await director.getItemVariables(item_id)
_LOGGER.warning(
(
@@ -132,7 +128,7 @@ async def async_setup_entry(
entity_list.append(
Control4Light(
- entry_data,
+ runtime_data,
item_coordinator,
item_name,
item_id,
@@ -154,7 +150,7 @@ class Control4Light(Control4Entity, LightEntity):
def __init__(
self,
- entry_data: dict,
+ runtime_data: Control4RuntimeData,
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
name: str,
idx: int,
@@ -166,7 +162,7 @@ class Control4Light(Control4Entity, LightEntity):
) -> None:
"""Initialize Control4 light entity."""
super().__init__(
- entry_data,
+ runtime_data,
coordinator,
name,
idx,
@@ -188,7 +184,7 @@ class Control4Light(Control4Entity, LightEntity):
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
"""
- return C4Light(self.entry_data[CONF_DIRECTOR], self._idx)
+ return C4Light(self.runtime_data.director, self._idx)
@property
def is_on(self):
diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py
index 9e3421817a3..bd8e3fb38fe 100644
--- a/homeassistant/components/control4/media_player.py
+++ b/homeassistant/components/control4/media_player.py
@@ -18,13 +18,11 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_DIRECTOR, CONF_DIRECTOR_ALL_ITEMS, CONF_UI_CONFIGURATION, DOMAIN
+from . import Control4ConfigEntry, Control4RuntimeData
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity
@@ -67,22 +65,23 @@ class _RoomSource:
name: str
-async def get_rooms(hass: HomeAssistant, entry: ConfigEntry):
+async def get_rooms(hass: HomeAssistant, entry: Control4ConfigEntry):
"""Return a list of all Control4 rooms."""
- director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS]
return [
item
- for item in director_all_items
+ for item in entry.runtime_data.director_all_items
if "typeName" in item and item["typeName"] == "room"
]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: Control4ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Control4 rooms from a config entry."""
- entry_data = hass.data[DOMAIN][entry.entry_id]
- ui_config = entry_data[CONF_UI_CONFIGURATION]
+ runtime_data = entry.runtime_data
+ ui_config = runtime_data.ui_configuration
# OS 2 will not have a ui_configuration
if not ui_config:
@@ -93,7 +92,7 @@ async def async_setup_entry(
if not all_rooms:
return
- scan_interval = entry_data[CONF_SCAN_INTERVAL]
+ scan_interval = runtime_data.scan_interval
_LOGGER.debug("Scan interval = %s", scan_interval)
async def async_update_data() -> dict[int, dict[str, Any]]:
@@ -116,10 +115,7 @@ async def async_setup_entry(
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
- items_by_id = {
- item["id"]: item
- for item in hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS]
- }
+ items_by_id = {item["id"]: item for item in runtime_data.director_all_items}
item_to_parent_map = {
k: item["parentId"]
for k, item in items_by_id.items()
@@ -156,7 +152,7 @@ async def async_setup_entry(
hidden = room["roomHidden"]
entity_list.append(
Control4Room(
- entry_data,
+ runtime_data,
coordinator,
room["name"],
room_id,
@@ -182,7 +178,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
def __init__(
self,
- entry_data: dict,
+ runtime_data: Control4RuntimeData,
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
name: str,
room_id: int,
@@ -192,7 +188,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
) -> None:
"""Initialize Control4 room entity."""
super().__init__(
- entry_data,
+ runtime_data,
coordinator,
None,
room_id,
@@ -220,7 +216,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
"""
- return C4Room(self.entry_data[CONF_DIRECTOR], self._idx)
+ return C4Room(self.runtime_data.director, self._idx)
def _get_device_from_variable(self, var: str) -> int | None:
current_device = self.coordinator.data[self._idx][var]
diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py
index 898b7b2cf4f..b110d53540c 100644
--- a/homeassistant/components/conversation/__init__.py
+++ b/homeassistant/components/conversation/__init__.py
@@ -48,20 +48,32 @@ from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
+from .session import (
+ ChatSession,
+ Content,
+ ConverseError,
+ NativeContent,
+ async_get_chat_session,
+)
from .trace import ConversationTraceEventType, async_conversation_trace_append
__all__ = [
"DOMAIN",
"HOME_ASSISTANT_AGENT",
"OLD_HOME_ASSISTANT_AGENT",
+ "ChatSession",
+ "Content",
"ConversationEntity",
"ConversationEntityFeature",
"ConversationInput",
"ConversationResult",
"ConversationTraceEventType",
+ "ConverseError",
+ "NativeContent",
"async_conversation_trace_append",
"async_converse",
"async_get_agent_info",
+ "async_get_chat_session",
"async_set_agent",
"async_setup",
"async_unset_agent",
diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py
index 7516d9d22ef..ce3a0cf028d 100644
--- a/homeassistant/components/conversation/agent_manager.py
+++ b/homeassistant/components/conversation/agent_manager.py
@@ -9,7 +9,8 @@ from typing import Any
import voluptuous as vol
from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
-from homeassistant.helpers import config_validation as cv, singleton
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv, intent, singleton
from .const import (
DATA_COMPONENT,
@@ -75,6 +76,7 @@ async def async_converse(
language: str | None = None,
agent_id: str | None = None,
device_id: str | None = None,
+ extra_system_prompt: str | None = None,
) -> ConversationResult:
"""Process text and get intent."""
agent = async_get_agent(hass, agent_id)
@@ -99,6 +101,7 @@ async def async_converse(
device_id=device_id,
language=language,
agent_id=agent_id,
+ extra_system_prompt=extra_system_prompt,
)
with async_conversation_trace() as trace:
trace.add_event(
@@ -107,7 +110,19 @@ async def async_converse(
dataclasses.asdict(conversation_input),
)
)
- result = await method(conversation_input)
+ try:
+ result = await method(conversation_input)
+ except HomeAssistantError as err:
+ intent_response = intent.IntentResponse(language=language)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ str(err),
+ )
+ result = ConversationResult(
+ response=intent_response,
+ conversation_id=conversation_id,
+ )
+
trace.set_result(**result.as_dict())
return result
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index 66ffb25fa1a..be0387555dc 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -62,6 +62,7 @@ from .const import (
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
+from .session import Content, async_get_chat_session
from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__)
@@ -346,35 +347,51 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
+ response: intent.IntentResponse | None = None
+ async with async_get_chat_session(self.hass, user_input) as chat_session:
+ # Check if a trigger matched
+ if trigger_result := await self.async_recognize_sentence_trigger(
+ user_input
+ ):
+ # Process callbacks and get response
+ response_text = await self._handle_trigger_result(
+ trigger_result, user_input
+ )
- # Check if a trigger matched
- if trigger_result := await self.async_recognize_sentence_trigger(user_input):
- # Process callbacks and get response
- response_text = await self._handle_trigger_result(
- trigger_result, user_input
+ # Convert to conversation result
+ response = intent.IntentResponse(
+ language=user_input.language or self.hass.config.language
+ )
+ response.response_type = intent.IntentResponseType.ACTION_DONE
+ response.async_set_speech(response_text)
+
+ if response is None:
+ # Match intents
+ intent_result = await self.async_recognize_intent(user_input)
+ response = await self._async_process_intent_result(
+ intent_result, user_input
+ )
+
+ speech: str = response.speech.get("plain", {}).get("speech", "")
+ chat_session.async_add_message(
+ Content(
+ role="assistant",
+ agent_id=user_input.agent_id,
+ content=speech,
+ )
)
- # Convert to conversation result
- response = intent.IntentResponse(
- language=user_input.language or self.hass.config.language
+ return ConversationResult(
+ response=response, conversation_id=chat_session.conversation_id
)
- response.response_type = intent.IntentResponseType.ACTION_DONE
- response.async_set_speech(response_text)
-
- return ConversationResult(response=response)
-
- # Match intents
- intent_result = await self.async_recognize_intent(user_input)
- return await self._async_process_intent_result(intent_result, user_input)
async def _async_process_intent_result(
self,
result: RecognizeResult | None,
user_input: ConversationInput,
- ) -> ConversationResult:
+ ) -> intent.IntentResponse:
"""Process user input with intents."""
language = user_input.language or self.hass.config.language
- conversation_id = None # Not supported
# Intent match or failure
lang_intents = await self.async_get_or_load_intents(language)
@@ -386,7 +403,6 @@ class DefaultAgent(ConversationEntity):
language,
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
self._get_error_text(ErrorKey.NO_INTENT, lang_intents),
- conversation_id,
)
if result.unmatched_entities:
@@ -408,7 +424,6 @@ class DefaultAgent(ConversationEntity):
self._get_error_text(
error_response_type, lang_intents, **error_response_args
),
- conversation_id,
)
# Will never happen because result will be None when no intents are
@@ -461,7 +476,6 @@ class DefaultAgent(ConversationEntity):
self._get_error_text(
error_response_type, lang_intents, **error_response_args
),
- conversation_id,
)
except intent.IntentHandleError as err:
# Intent was valid and entities matched constraints, but an error
@@ -473,7 +487,6 @@ class DefaultAgent(ConversationEntity):
self._get_error_text(
err.response_key or ErrorKey.HANDLE_ERROR, lang_intents
),
- conversation_id,
)
except intent.IntentUnexpectedError:
_LOGGER.exception("Unexpected intent error")
@@ -481,7 +494,6 @@ class DefaultAgent(ConversationEntity):
language,
intent.IntentResponseErrorCode.UNKNOWN,
self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents),
- conversation_id,
)
if (
@@ -500,9 +512,7 @@ class DefaultAgent(ConversationEntity):
)
intent_response.async_set_speech(speech)
- return ConversationResult(
- response=intent_response, conversation_id=conversation_id
- )
+ return intent_response
def _recognize(
self,
@@ -787,36 +797,13 @@ class DefaultAgent(ConversationEntity):
intent_response: intent.IntentResponse,
recognize_result: RecognizeResult,
) -> str:
- # Make copies of the states here so we can add translated names for responses.
- matched = [
- state_copy
- for state in intent_response.matched_states
- if (state_copy := core.State.from_dict(state.as_dict()))
- ]
- unmatched = [
- state_copy
- for state in intent_response.unmatched_states
- if (state_copy := core.State.from_dict(state.as_dict()))
- ]
- all_states = matched + unmatched
- domains = {state.domain for state in all_states}
- translations = await translation.async_get_translations(
- self.hass, language, "entity_component", domains
- )
-
- # Use translated state names
- for state in all_states:
- device_class = state.attributes.get("device_class", "_")
- key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}"
- state.state = translations.get(key, state.state)
-
# Get first matched or unmatched state.
# This is available in the response template as "state".
state1: core.State | None = None
if intent_response.matched_states:
- state1 = matched[0]
+ state1 = intent_response.matched_states[0]
elif intent_response.unmatched_states:
- state1 = unmatched[0]
+ state1 = intent_response.unmatched_states[0]
# Render response template
speech_slots = {
@@ -838,11 +825,13 @@ class DefaultAgent(ConversationEntity):
"query": {
# Entity states that matched the query (e.g, "on")
"matched": [
- template.TemplateState(self.hass, state) for state in matched
+ template.TemplateState(self.hass, state)
+ for state in intent_response.matched_states
],
# Entity states that did not match the query
"unmatched": [
- template.TemplateState(self.hass, state) for state in unmatched
+ template.TemplateState(self.hass, state)
+ for state in intent_response.unmatched_states
],
},
}
@@ -1339,29 +1328,36 @@ class DefaultAgent(ConversationEntity):
"""Try to match sentence against registered intents and return response.
Only performs strict matching with exposed entities and exact wording.
- Returns None if no match occurred.
+ Returns None if no match or a matching error occurred.
"""
result = await self.async_recognize_intent(user_input, strict_intents_only=True)
if not isinstance(result, RecognizeResult):
# No error message on failed match
return None
- conversation_result = await self._async_process_intent_result(
- result, user_input
- )
- return conversation_result.response
+ response = await self._async_process_intent_result(result, user_input)
+ if (
+ response.response_type == intent.IntentResponseType.ERROR
+ and response.error_code
+ not in (
+ intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
+ intent.IntentResponseErrorCode.UNKNOWN,
+ )
+ ):
+ # We ignore no matching errors
+ return None
+ return response
def _make_error_result(
language: str,
error_code: intent.IntentResponseErrorCode,
response_text: str,
- conversation_id: str | None = None,
-) -> ConversationResult:
+) -> intent.IntentResponse:
"""Create conversation result with error code and text."""
response = intent.IntentResponse(language=language)
response.async_set_error(error_code, response_text)
- return ConversationResult(response, conversation_id)
+ return response
def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]:
@@ -1488,12 +1484,6 @@ def _get_match_error_response(
# Entity is not in correct state
assert constraints.states
state = next(iter(constraints.states))
- if constraints.domains:
- # Translate if domain is available
- domain = next(iter(constraints.domains))
- state = translation.async_translate_state(
- hass, state, domain, None, None, None
- )
return ErrorKey.ENTITY_WRONG_STATE, {"state": state}
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 979ea7538c4..2d4a8053d75 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==2.1.0", "home-assistant-intents==2025.1.1"]
+ "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
}
diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py
index 10218e76751..9462c597f23 100644
--- a/homeassistant/components/conversation/models.py
+++ b/homeassistant/components/conversation/models.py
@@ -40,6 +40,9 @@ class ConversationInput:
agent_id: str | None = None
"""Agent to use for processing."""
+ extra_system_prompt: str | None = None
+ """Extra prompt to provide extra info to LLMs how to understand the command."""
+
def as_dict(self) -> dict[str, Any]:
"""Return input as a dict."""
return {
@@ -49,6 +52,7 @@ class ConversationInput:
"device_id": self.device_id,
"language": self.language,
"agent_id": self.agent_id,
+ "extra_system_prompt": self.extra_system_prompt,
}
diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py
new file mode 100644
index 00000000000..43f4cbf427c
--- /dev/null
+++ b/homeassistant/components/conversation/session.py
@@ -0,0 +1,359 @@
+"""Conversation history."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field, replace
+from datetime import datetime, timedelta
+import logging
+from typing import Literal
+
+import voluptuous as vol
+
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ Event,
+ HassJob,
+ HassJobType,
+ HomeAssistant,
+ callback,
+)
+from homeassistant.exceptions import HomeAssistantError, TemplateError
+from homeassistant.helpers import intent, llm, template
+from homeassistant.helpers.event import async_call_later
+from homeassistant.util import dt as dt_util, ulid as ulid_util
+from homeassistant.util.hass_dict import HassKey
+from homeassistant.util.json import JsonObjectType
+
+from . import trace
+from .const import DOMAIN
+from .models import ConversationInput, ConversationResult
+
+DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey(
+ "conversation_chat_session"
+)
+DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey(
+ "conversation_chat_session_cleanup"
+)
+
+LOGGER = logging.getLogger(__name__)
+CONVERSATION_TIMEOUT = timedelta(minutes=5)
+
+
+class SessionCleanup:
+ """Helper to clean up the history."""
+
+ unsub: CALLBACK_TYPE | None = None
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the history cleanup."""
+ self.hass = hass
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop)
+ self.cleanup_job = HassJob(
+ self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback
+ )
+
+ @callback
+ def schedule(self) -> None:
+ """Schedule the cleanup."""
+ if self.unsub:
+ return
+ self.unsub = async_call_later(
+ self.hass,
+ CONVERSATION_TIMEOUT.total_seconds() + 1,
+ self.cleanup_job,
+ )
+
+ @callback
+ def _on_hass_stop(self, event: Event) -> None:
+ """Cancel the cleanup on shutdown."""
+ if self.unsub:
+ self.unsub()
+ self.unsub = None
+
+ @callback
+ def _cleanup(self, now: datetime) -> None:
+ """Clean up the history and schedule follow-up if necessary."""
+ self.unsub = None
+ all_history = self.hass.data[DATA_CHAT_HISTORY]
+
+ # We mutate original object because current commands could be
+ # yielding history based on it.
+ for conversation_id, history in list(all_history.items()):
+ if history.last_updated + CONVERSATION_TIMEOUT < now:
+ del all_history[conversation_id]
+
+ # Still conversations left, check again in timeout time.
+ if all_history:
+ self.schedule()
+
+
+@asynccontextmanager
+async def async_get_chat_session(
+ hass: HomeAssistant,
+ user_input: ConversationInput,
+) -> AsyncGenerator[ChatSession]:
+ """Return chat session."""
+ all_history = hass.data.get(DATA_CHAT_HISTORY)
+ if all_history is None:
+ all_history = {}
+ hass.data[DATA_CHAT_HISTORY] = all_history
+ hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass)
+
+ history: ChatSession | None = None
+
+ if user_input.conversation_id is None:
+ conversation_id = ulid_util.ulid_now()
+
+ elif history := all_history.get(user_input.conversation_id):
+ conversation_id = user_input.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_util.ulid_to_bytes(user_input.conversation_id)
+ conversation_id = ulid_util.ulid_now()
+ except ValueError:
+ conversation_id = user_input.conversation_id
+
+ if history:
+ history = replace(history, messages=history.messages.copy())
+ else:
+ history = ChatSession(hass, conversation_id, user_input.agent_id)
+
+ message: Content = Content(
+ role="user",
+ agent_id=user_input.agent_id,
+ content=user_input.text,
+ )
+ history.async_add_message(message)
+
+ yield history
+
+ if history.messages[-1] is message:
+ LOGGER.debug(
+ "History opened but no assistant message was added, ignoring update"
+ )
+ return
+
+ history.last_updated = dt_util.utcnow()
+ all_history[conversation_id] = history
+ hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule()
+
+
+class ConverseError(HomeAssistantError):
+ """Error during initialization of conversation.
+
+ Will not be stored in the history.
+ """
+
+ def __init__(
+ self, message: str, conversation_id: str, response: intent.IntentResponse
+ ) -> None:
+ """Initialize the error."""
+ super().__init__(message)
+ self.conversation_id = conversation_id
+ self.response = response
+
+ def as_conversation_result(self) -> ConversationResult:
+ """Return the error as a conversation result."""
+ return ConversationResult(
+ response=self.response,
+ conversation_id=self.conversation_id,
+ )
+
+
+@dataclass
+class Content:
+ """Base class for chat messages."""
+
+ role: Literal["system", "assistant", "user"]
+ agent_id: str | None
+ content: str
+
+
+@dataclass(frozen=True)
+class NativeContent[_NativeT]:
+ """Native content."""
+
+ role: str = field(init=False, default="native")
+ agent_id: str
+ content: _NativeT
+
+
+@dataclass
+class ChatSession[_NativeT]:
+ """Class holding all information for a specific conversation."""
+
+ hass: HomeAssistant
+ conversation_id: str
+ agent_id: str | None
+ user_name: str | None = None
+ messages: list[Content | NativeContent[_NativeT]] = field(
+ default_factory=lambda: [Content(role="system", agent_id=None, content="")]
+ )
+ extra_system_prompt: str | None = None
+ llm_api: llm.APIInstance | None = None
+ last_updated: datetime = field(default_factory=dt_util.utcnow)
+
+ @callback
+ def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None:
+ """Process intent."""
+ if message.role == "system":
+ raise ValueError("Cannot add system messages to history")
+ if message.role != "native" and self.messages[-1].role == message.role:
+ raise ValueError("Cannot add two assistant or user messages in a row")
+
+ self.messages.append(message)
+
+ @callback
+ def async_get_messages(
+ self, agent_id: str | None = None
+ ) -> list[Content | NativeContent[_NativeT]]:
+ """Get messages for a specific agent ID.
+
+ This will filter out any native message tied to other agent IDs.
+ It can still include assistant/user messages generated by other agents.
+ """
+ return [
+ message
+ for message in self.messages
+ if message.role != "native" or message.agent_id == agent_id
+ ]
+
+ async def async_update_llm_data(
+ self,
+ conversing_domain: str,
+ user_input: ConversationInput,
+ user_llm_hass_api: str | None = None,
+ user_llm_prompt: str | None = None,
+ ) -> None:
+ """Set the LLM system prompt."""
+ llm_context = llm.LLMContext(
+ platform=conversing_domain,
+ context=user_input.context,
+ user_prompt=user_input.text,
+ language=user_input.language,
+ assistant=DOMAIN,
+ device_id=user_input.device_id,
+ )
+
+ llm_api: llm.APIInstance | None = None
+
+ if user_llm_hass_api:
+ try:
+ llm_api = await llm.async_get_api(
+ self.hass,
+ user_llm_hass_api,
+ llm_context,
+ )
+ except HomeAssistantError as err:
+ LOGGER.error(
+ "Error getting LLM API %s for %s: %s",
+ user_llm_hass_api,
+ conversing_domain,
+ err,
+ )
+ intent_response = intent.IntentResponse(language=user_input.language)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ "Error preparing LLM API",
+ )
+ raise ConverseError(
+ f"Error getting LLM API {user_llm_hass_api}",
+ conversation_id=self.conversation_id,
+ response=intent_response,
+ ) from err
+
+ user_name: str | None = None
+
+ 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
+ + (user_llm_prompt or 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 = intent.IntentResponse(language=user_input.language)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ "Sorry, I had a problem with my template",
+ )
+ raise ConverseError(
+ "Error rendering prompt",
+ conversation_id=self.conversation_id,
+ response=intent_response,
+ ) from err
+
+ if llm_api:
+ prompt_parts.append(llm_api.api_prompt)
+
+ extra_system_prompt = (
+ # Take new system prompt if one was given
+ user_input.extra_system_prompt or self.extra_system_prompt
+ )
+
+ if extra_system_prompt:
+ prompt_parts.append(extra_system_prompt)
+
+ prompt = "\n".join(prompt_parts)
+
+ self.llm_api = llm_api
+ self.user_name = user_name
+ self.extra_system_prompt = extra_system_prompt
+ self.messages[0] = Content(
+ role="system",
+ agent_id=user_input.agent_id,
+ content=prompt,
+ )
+
+ LOGGER.debug("Prompt: %s", self.messages)
+ LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
+
+ trace.async_conversation_trace_append(
+ trace.ConversationTraceEventType.AGENT_DETAIL,
+ {
+ "messages": self.messages,
+ "tools": self.llm_api.tools if self.llm_api else None,
+ },
+ )
+
+ async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType:
+ """Invoke LLM tool for the configured LLM API."""
+ if not self.llm_api:
+ raise ValueError("No LLM API configured")
+ LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args)
+
+ try:
+ tool_response = await self.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)
+ return tool_response
diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py
index 24eb54c5694..752e294a8b3 100644
--- a/homeassistant/components/conversation/trigger.py
+++ b/homeassistant/components/conversation/trigger.py
@@ -5,12 +5,17 @@ from __future__ import annotations
from typing import Any
from hassil.recognize import RecognizeResult
-from hassil.util import PUNCTUATION_ALL
+from hassil.util import (
+ PUNCTUATION_END,
+ PUNCTUATION_END_WORD,
+ PUNCTUATION_START,
+ PUNCTUATION_START_WORD,
+)
import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.script import ScriptRunResult
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import UNDEFINED, ConfigType
@@ -22,7 +27,12 @@ from .models import ConversationInput
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
- if PUNCTUATION_ALL.search(sentence):
+ if (
+ PUNCTUATION_START.search(sentence)
+ or PUNCTUATION_END.search(sentence)
+ or PUNCTUATION_START_WORD.search(sentence)
+ or PUNCTUATION_END_WORD.search(sentence)
+ ):
raise vol.Invalid("sentence should not contain punctuation")
return value
diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py
index 5d3c211e78d..bff4c8123d6 100644
--- a/homeassistant/components/cookidoo/__init__.py
+++ b/homeassistant/components/cookidoo/__init__.py
@@ -2,41 +2,29 @@
from __future__ import annotations
-from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
+import logging
-from homeassistant.const import (
- CONF_COUNTRY,
- CONF_EMAIL,
- CONF_LANGUAGE,
- CONF_PASSWORD,
- Platform,
-)
+from cookidoo_api import CookidooAuthException, CookidooRequestException
+
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
+from .helpers import cookidoo_from_config_entry
-PLATFORMS: list[Platform] = [Platform.TODO]
+PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO]
+
+_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
"""Set up Cookidoo from a config entry."""
- localizations = await get_localization_options(
- country=entry.data[CONF_COUNTRY].lower(),
- language=entry.data[CONF_LANGUAGE],
+ coordinator = CookidooDataUpdateCoordinator(
+ hass, await cookidoo_from_config_entry(hass, entry), entry
)
-
- cookidoo = Cookidoo(
- async_get_clientsession(hass),
- CookidooConfig(
- email=entry.data[CONF_EMAIL],
- password=entry.data[CONF_PASSWORD],
- localization=localizations[0],
- ),
- )
-
- coordinator = CookidooDataUpdateCoordinator(hass, cookidoo, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -49,3 +37,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: CookidooConfigEntry
+) -> bool:
+ """Migrate config entry."""
+ _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+ if config_entry.version == 1 and config_entry.minor_version == 1:
+ # Add the unique uuid
+ cookidoo = await cookidoo_from_config_entry(hass, config_entry)
+
+ try:
+ auth_data = await cookidoo.login()
+ except (CookidooRequestException, CookidooAuthException) as e:
+ _LOGGER.error(
+ "Could not migrate config config_entry: %s",
+ str(e),
+ )
+ return False
+
+ unique_id = auth_data.sub
+
+ device_registry = dr.async_get(hass)
+ entity_registry = er.async_get(hass)
+ device_entries = dr.async_entries_for_config_entry(
+ device_registry, config_entry_id=config_entry.entry_id
+ )
+ entity_entries = er.async_entries_for_config_entry(
+ entity_registry, config_entry_id=config_entry.entry_id
+ )
+ for dev in device_entries:
+ device_registry.async_update_device(
+ dev.id, new_identifiers={(DOMAIN, unique_id)}
+ )
+ for ent in entity_entries:
+ assert ent.config_entry_id
+ entity_registry.async_update_entity(
+ ent.entity_id,
+ new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id),
+ )
+
+ hass.config_entries.async_update_entry(
+ config_entry, unique_id=auth_data.sub, minor_version=2
+ )
+
+ _LOGGER.debug(
+ "Migration to version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py
new file mode 100644
index 00000000000..b292a7309ba
--- /dev/null
+++ b/homeassistant/components/cookidoo/button.py
@@ -0,0 +1,71 @@
+"""Support for Cookidoo buttons."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from cookidoo_api import Cookidoo, CookidooException
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
+from .entity import CookidooBaseEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class CookidooButtonEntityDescription(ButtonEntityDescription):
+ """Describes cookidoo button entity."""
+
+ press_fn: Callable[[Cookidoo], Awaitable[None]]
+
+
+TODO_CLEAR = CookidooButtonEntityDescription(
+ key="todo_clear",
+ translation_key="todo_clear",
+ press_fn=lambda client: client.clear_shopping_list(),
+ entity_registry_enabled_default=False,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: CookidooConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Cookidoo button entities based on a config entry."""
+ coordinator = entry.runtime_data
+
+ async_add_entities([CookidooButton(coordinator, TODO_CLEAR)])
+
+
+class CookidooButton(CookidooBaseEntity, ButtonEntity):
+ """Defines an Cookidoo button."""
+
+ entity_description: CookidooButtonEntityDescription
+
+ def __init__(
+ self,
+ coordinator: CookidooDataUpdateCoordinator,
+ description: CookidooButtonEntityDescription,
+ ) -> None:
+ """Initialize cookidoo button."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ assert coordinator.config_entry.unique_id
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ try:
+ await self.entity_description.press_fn(self.coordinator.cookidoo)
+ except CookidooException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="button_clear_todo_failed",
+ ) from e
+ await self.coordinator.async_refresh()
diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py
index 80487ed757f..71ad3015730 100644
--- a/homeassistant/components/cookidoo/config_flow.py
+++ b/homeassistant/components/cookidoo/config_flow.py
@@ -7,9 +7,7 @@ import logging
from typing import Any
from cookidoo_api import (
- Cookidoo,
CookidooAuthException,
- CookidooConfig,
CookidooRequestException,
get_country_options,
get_localization_options,
@@ -23,7 +21,6 @@ from homeassistant.config_entries import (
ConfigFlowResult,
)
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
@@ -35,6 +32,7 @@ from homeassistant.helpers.selector import (
)
from .const import DOMAIN
+from .helpers import cookidoo_from_config_data
_LOGGER = logging.getLogger(__name__)
@@ -57,10 +55,14 @@ AUTH_DATA_SCHEMA = {
class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cookidoo."""
+ VERSION = 1
+ MINOR_VERSION = 2
+
COUNTRY_DATA_SCHEMA: dict
LANGUAGE_DATA_SCHEMA: dict
user_input: dict[str, Any]
+ user_uuid: str
async def async_step_reconfigure(
self, user_input: dict[str, Any]
@@ -78,8 +80,11 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None and not (
errors := await self.validate_input(user_input)
):
+ await self.async_set_unique_id(self.user_uuid)
if self.source == SOURCE_USER:
- self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
+ self._abort_if_unique_id_configured()
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch()
self.user_input = user_input
return await self.async_step_language()
await self.generate_country_schema()
@@ -153,10 +158,8 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
if not (
errors := await self.validate_input({**reauth_entry.data, **user_input})
):
- if user_input[CONF_EMAIL] != reauth_entry.data[CONF_EMAIL]:
- self._async_abort_entries_match(
- {CONF_EMAIL: user_input[CONF_EMAIL]}
- )
+ await self.async_set_unique_id(self.user_uuid)
+ self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input
)
@@ -220,21 +223,10 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
await get_localization_options(country=data_input[CONF_COUNTRY].lower())
)[0].language # Pick any language to test login
- localizations = await get_localization_options(
- country=data_input[CONF_COUNTRY].lower(),
- language=data_input[CONF_LANGUAGE],
- )
-
- cookidoo = Cookidoo(
- async_get_clientsession(self.hass),
- CookidooConfig(
- email=data_input[CONF_EMAIL],
- password=data_input[CONF_PASSWORD],
- localization=localizations[0],
- ),
- )
+ cookidoo = await cookidoo_from_config_data(self.hass, data_input)
try:
- await cookidoo.login()
+ auth_data = await cookidoo.login()
+ self.user_uuid = auth_data.sub
if language_input:
await cookidoo.get_additional_items()
except CookidooRequestException:
diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py
index 37c584404a0..0381e18725d 100644
--- a/homeassistant/components/cookidoo/const.py
+++ b/homeassistant/components/cookidoo/const.py
@@ -1,3 +1,9 @@
"""Constants for the Cookidoo integration."""
DOMAIN = "cookidoo"
+
+SUBSCRIPTION_MAP = {
+ "NONE": "free",
+ "TRIAL": "trial",
+ "REGULAR": "premium",
+}
diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py
index ad86d1fb9f1..2ce61306afe 100644
--- a/homeassistant/components/cookidoo/coordinator.py
+++ b/homeassistant/components/cookidoo/coordinator.py
@@ -13,6 +13,8 @@ from cookidoo_api import (
CookidooException,
CookidooIngredientItem,
CookidooRequestException,
+ CookidooSubscription,
+ CookidooUserInfo,
)
from homeassistant.config_entries import ConfigEntry
@@ -34,12 +36,14 @@ class CookidooData:
ingredient_items: list[CookidooIngredientItem]
additional_items: list[CookidooAdditionalItem]
+ subscription: CookidooSubscription | None
class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
"""A Cookidoo Data Update Coordinator."""
config_entry: CookidooConfigEntry
+ user: CookidooUserInfo
def __init__(
self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry
@@ -57,6 +61,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
async def _async_setup(self) -> None:
try:
await self.cookidoo.login()
+ self.user = await self.cookidoo.get_user_info()
except CookidooRequestException as e:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -75,6 +80,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
try:
ingredient_items = await self.cookidoo.get_ingredient_items()
additional_items = await self.cookidoo.get_additional_items()
+ subscription = await self.cookidoo.get_active_subscription()
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
@@ -97,5 +103,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
) from e
return CookidooData(
- ingredient_items=ingredient_items, additional_items=additional_items
+ ingredient_items=ingredient_items,
+ additional_items=additional_items,
+ subscription=subscription,
)
diff --git a/homeassistant/components/cookidoo/diagnostics.py b/homeassistant/components/cookidoo/diagnostics.py
new file mode 100644
index 00000000000..f981317df19
--- /dev/null
+++ b/homeassistant/components/cookidoo/diagnostics.py
@@ -0,0 +1,26 @@
+"""Diagnostics for the Cookidoo integration."""
+
+from dataclasses import asdict
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+
+from .coordinator import CookidooConfigEntry
+
+TO_REDACT = [
+ CONF_PASSWORD,
+]
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: CookidooConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ return {
+ "entry_data": async_redact_data(entry.data, TO_REDACT),
+ "data": asdict(entry.runtime_data.data),
+ "user": asdict(entry.runtime_data.user),
+ }
diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py
index 5c8f3ec8441..97ebb384ecb 100644
--- a/homeassistant/components/cookidoo/entity.py
+++ b/homeassistant/components/cookidoo/entity.py
@@ -21,10 +21,12 @@ class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]):
"""Initialize the entity."""
super().__init__(coordinator)
+ assert coordinator.config_entry.unique_id
+
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name="Cookidoo",
- identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
+ identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
manufacturer="Vorwerk International & Co. KmG",
model="Cookidoo - Thermomix® recipe portal",
)
diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py
new file mode 100644
index 00000000000..199abb2e05d
--- /dev/null
+++ b/homeassistant/components/cookidoo/helpers.py
@@ -0,0 +1,37 @@
+"""Helpers for cookidoo."""
+
+from typing import Any
+
+from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
+
+from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import CookidooConfigEntry
+
+
+async def cookidoo_from_config_data(
+ hass: HomeAssistant, data: dict[str, Any]
+) -> Cookidoo:
+ """Build cookidoo from config data."""
+ localizations = await get_localization_options(
+ country=data[CONF_COUNTRY].lower(),
+ language=data[CONF_LANGUAGE],
+ )
+
+ return Cookidoo(
+ async_get_clientsession(hass),
+ CookidooConfig(
+ email=data[CONF_EMAIL],
+ password=data[CONF_PASSWORD],
+ localization=localizations[0],
+ ),
+ )
+
+
+async def cookidoo_from_config_entry(
+ hass: HomeAssistant, entry: CookidooConfigEntry
+) -> Cookidoo:
+ """Build cookidoo from config entry."""
+ return await cookidoo_from_config_data(hass, dict(entry.data))
diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json
index 36c0724331a..cf4d9dc2858 100644
--- a/homeassistant/components/cookidoo/icons.json
+++ b/homeassistant/components/cookidoo/icons.json
@@ -1,5 +1,23 @@
{
"entity": {
+ "sensor": {
+ "subscription": {
+ "default": "mdi:account",
+ "state": {
+ "free": "mdi:account",
+ "trial": "mdi:account-question",
+ "regular": "mdi:account-star"
+ }
+ },
+ "expiration": {
+ "default": "mdi:account-reactivate"
+ }
+ },
+ "button": {
+ "todo_clear": {
+ "default": "mdi:cart-off"
+ }
+ },
"todo": {
"ingredient_list": {
"default": "mdi:cart-plus"
diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml
index 95a35829079..209f2ce5686 100644
--- a/homeassistant/components/cookidoo/quality_scale.yaml
+++ b/homeassistant/components/cookidoo/quality_scale.yaml
@@ -63,7 +63,7 @@ rules:
stale-devices:
status: exempt
comment: No stale entities possible
- diagnostics: todo
+ diagnostics: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py
new file mode 100644
index 00000000000..7fbacea18bc
--- /dev/null
+++ b/homeassistant/components/cookidoo/sensor.py
@@ -0,0 +1,111 @@
+"""Sensor platform for the Cookidoo integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from enum import StrEnum
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.util import dt as dt_util
+
+from .const import SUBSCRIPTION_MAP
+from .coordinator import (
+ CookidooConfigEntry,
+ CookidooData,
+ CookidooDataUpdateCoordinator,
+)
+from .entity import CookidooBaseEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(kw_only=True, frozen=True)
+class CookidooSensorEntityDescription(SensorEntityDescription):
+ """Cookidoo Sensor Description."""
+
+ value_fn: Callable[[CookidooData], StateType | datetime]
+
+
+class CookidooSensor(StrEnum):
+ """Cookidoo sensors."""
+
+ SUBSCRIPTION = "subscription"
+ EXPIRES = "expires"
+
+
+SENSOR_DESCRIPTIONS: tuple[CookidooSensorEntityDescription, ...] = (
+ CookidooSensorEntityDescription(
+ key=CookidooSensor.SUBSCRIPTION,
+ translation_key=CookidooSensor.SUBSCRIPTION,
+ value_fn=(
+ lambda data: SUBSCRIPTION_MAP[data.subscription.type]
+ if data.subscription
+ else SUBSCRIPTION_MAP["NONE"]
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=list(SUBSCRIPTION_MAP.values()),
+ device_class=SensorDeviceClass.ENUM,
+ ),
+ CookidooSensorEntityDescription(
+ key=CookidooSensor.EXPIRES,
+ translation_key=CookidooSensor.EXPIRES,
+ value_fn=(
+ lambda data: dt_util.parse_datetime(data.subscription.expires)
+ if data.subscription
+ else None
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: CookidooConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the sensor platform."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ CookidooSensorEntity(
+ coordinator,
+ description,
+ )
+ for description in SENSOR_DESCRIPTIONS
+ )
+
+
+class CookidooSensorEntity(CookidooBaseEntity, SensorEntity):
+ """A sensor entity."""
+
+ entity_description: CookidooSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: CookidooDataUpdateCoordinator,
+ entity_description: CookidooSensorEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self._attr_unique_id = (
+ f"{coordinator.config_entry.unique_id}_{self.entity_description.key}"
+ )
+
+ @property
+ def native_value(self) -> StateType | datetime:
+ """Return the state of the sensor."""
+
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json
index 14344bed13d..ae384fb6635 100644
--- a/homeassistant/components/cookidoo/strings.json
+++ b/homeassistant/components/cookidoo/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "title": "Setup {cookidoo}",
+ "title": "Set up {cookidoo}",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
@@ -15,7 +15,7 @@
}
},
"language": {
- "title": "Setup {cookidoo}",
+ "title": "[%key:component::cookidoo::config::step::user::title%]",
"data": {
"language": "[%key:common::config_flow::data::language%]"
},
@@ -24,7 +24,7 @@
}
},
"reauth_confirm": {
- "title": "Login again to {cookidoo}",
+ "title": "Log in again to {cookidoo}",
"description": "Please log in to {cookidoo} again to continue using this integration.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
@@ -44,10 +44,29 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "unique_id_mismatch": "The user identifier does not match the previous identifier"
}
},
"entity": {
+ "sensor": {
+ "subscription": {
+ "name": "Subscription",
+ "state": {
+ "free": "Free",
+ "trial": "Trial",
+ "premium": "Premium"
+ }
+ },
+ "expires": {
+ "name": "Subscription expiration date"
+ }
+ },
+ "button": {
+ "todo_clear": {
+ "name": "Clear shopping list and additional purchases"
+ }
+ },
"todo": {
"ingredient_list": {
"name": "Shopping list"
@@ -58,6 +77,9 @@
}
},
"exceptions": {
+ "button_clear_todo_failed": {
+ "message": "Failed to clear all items from the Cookidoo shopping list"
+ },
"todo_save_item_failed": {
"message": "Failed to save {name} to Cookidoo shopping list"
},
diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py
index 4a70dadc65a..3d5264f4e01 100644
--- a/homeassistant/components/cookidoo/todo.py
+++ b/homeassistant/components/cookidoo/todo.py
@@ -52,7 +52,8 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity):
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
- self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients"
+ assert coordinator.config_entry.unique_id
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_ingredients"
@property
def todo_items(self) -> list[TodoItem]:
@@ -112,7 +113,8 @@ class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity):
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
- self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items"
+ assert coordinator.config_entry.unique_id
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_additional_items"
@property
def todo_items(self) -> list[TodoItem]:
diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py
index 1f3f5a66380..5892ef091d9 100644
--- a/homeassistant/components/coolmaster/__init__.py
+++ b/homeassistant/components/coolmaster/__init__.py
@@ -2,18 +2,17 @@
from pycoolmasternet_async import CoolMasterNet
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN
-from .coordinator import CoolmasterDataUpdateCoordinator
+from .const import CONF_SWING_SUPPORT
+from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
"""Set up Coolmaster from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
@@ -38,21 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady
except OSError as error:
raise ConfigEntryNotReady from error
- coordinator = CoolmasterDataUpdateCoordinator(hass, coolmaster)
- hass.data.setdefault(DOMAIN, {})
+ coordinator = CoolmasterDataUpdateCoordinator(hass, entry, coolmaster, info)
await coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_INFO: info,
- DATA_COORDINATOR: coordinator,
- }
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
"""Unload a Coolmaster config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py
index ba54a073f0a..ab2718b9352 100644
--- a/homeassistant/components/coolmaster/binary_sensor.py
+++ b/homeassistant/components/coolmaster/binary_sensor.py
@@ -7,26 +7,23 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-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 DATA_COORDINATOR, DATA_INFO, DOMAIN
+from .coordinator import CoolmasterConfigEntry
from .entity import CoolmasterEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CoolmasterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet binary_sensor platform."""
- info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO]
- coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
+ coordinator = config_entry.runtime_data
async_add_entities(
- CoolmasterCleanFilter(coordinator, unit_id, info)
- for unit_id in coordinator.data
+ CoolmasterCleanFilter(coordinator, unit_id) for unit_id in coordinator.data
)
diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py
index d958346614c..5463566d1ef 100644
--- a/homeassistant/components/coolmaster/button.py
+++ b/homeassistant/components/coolmaster/button.py
@@ -3,26 +3,23 @@
from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
-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 DATA_COORDINATOR, DATA_INFO, DOMAIN
+from .coordinator import CoolmasterConfigEntry
from .entity import CoolmasterEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CoolmasterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet button platform."""
- info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO]
- coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
+ coordinator = config_entry.runtime_data
async_add_entities(
- CoolmasterResetFilter(coordinator, unit_id, info)
- for unit_id in coordinator.data
+ CoolmasterResetFilter(coordinator, unit_id) for unit_id in coordinator.data
)
diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py
index 29be416d57e..cd1659e1666 100644
--- a/homeassistant/components/coolmaster/climate.py
+++ b/homeassistant/components/coolmaster/climate.py
@@ -12,13 +12,13 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import CONF_SUPPORTED_MODES, DATA_COORDINATOR, DATA_INFO, DOMAIN
+from .const import CONF_SUPPORTED_MODES
+from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
from .entity import CoolmasterEntity
CM_TO_HA_STATE = {
@@ -38,15 +38,16 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CoolmasterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet climate platform."""
- info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO]
- coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
- supported_modes = config_entry.data.get(CONF_SUPPORTED_MODES)
+ coordinator = config_entry.runtime_data
+ supported_modes: list[str] = config_entry.data[CONF_SUPPORTED_MODES]
async_add_entities(
- CoolmasterClimate(coordinator, unit_id, info, supported_modes)
+ CoolmasterClimate(
+ coordinator, unit_id, [HVACMode(mode) for mode in supported_modes]
+ )
for unit_id in coordinator.data
)
@@ -56,9 +57,14 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
_attr_name = None
- def __init__(self, coordinator, unit_id, info, supported_modes):
+ def __init__(
+ self,
+ coordinator: CoolmasterDataUpdateCoordinator,
+ unit_id: str,
+ supported_modes: list[HVACMode],
+ ) -> None:
"""Initialize the climate device."""
- super().__init__(coordinator, unit_id, info)
+ super().__init__(coordinator, unit_id)
self._attr_hvac_modes = supported_modes
self._attr_unique_id = unit_id
diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py
index 1fa46e20ee9..9dd7ed3a444 100644
--- a/homeassistant/components/coolmaster/const.py
+++ b/homeassistant/components/coolmaster/const.py
@@ -1,8 +1,5 @@
"""Constants for the Coolmaster integration."""
-DATA_INFO = "info"
-DATA_COORDINATOR = "coordinator"
-
DOMAIN = "coolmaster"
DEFAULT_PORT = 10102
diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py
index 54d69b1c540..b2c96ca12a4 100644
--- a/homeassistant/components/coolmaster/coordinator.py
+++ b/homeassistant/components/coolmaster/coordinator.py
@@ -1,8 +1,15 @@
"""DataUpdateCoordinator for coolmaster integration."""
+from __future__ import annotations
+
import logging
+from pycoolmasternet_async import CoolMasterNet
+from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit
+
from homeassistant.components.climate import SCAN_INTERVAL
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -10,21 +17,34 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator):
+type CoolmasterConfigEntry = ConfigEntry[CoolmasterDataUpdateCoordinator]
+
+
+class CoolmasterDataUpdateCoordinator(
+ DataUpdateCoordinator[dict[str, CoolMasterNetUnit]]
+):
"""Class to manage fetching Coolmaster data."""
- def __init__(self, hass, coolmaster):
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: CoolmasterConfigEntry,
+ coolmaster: CoolMasterNet,
+ info: dict[str, str],
+ ) -> None:
"""Initialize global Coolmaster data updater."""
self._coolmaster = coolmaster
+ self.info = info
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
- async def _async_update_data(self):
+ async def _async_update_data(self) -> dict[str, CoolMasterNetUnit]:
"""Fetch data from Coolmaster."""
try:
return await self._coolmaster.status()
diff --git a/homeassistant/components/coolmaster/entity.py b/homeassistant/components/coolmaster/entity.py
index 73bd1e13a26..7d7bd8e62ba 100644
--- a/homeassistant/components/coolmaster/entity.py
+++ b/homeassistant/components/coolmaster/entity.py
@@ -1,7 +1,5 @@
"""Base entity for Coolmaster integration."""
-from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit
-
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -19,18 +17,17 @@ class CoolmasterEntity(CoordinatorEntity[CoolmasterDataUpdateCoordinator]):
self,
coordinator: CoolmasterDataUpdateCoordinator,
unit_id: str,
- info: dict[str, str],
) -> None:
"""Initiate CoolmasterEntity."""
super().__init__(coordinator)
self._unit_id: str = unit_id
- self._unit: CoolMasterNetUnit = coordinator.data[self._unit_id]
+ self._unit = coordinator.data[self._unit_id]
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, unit_id)},
manufacturer="CoolAutomation",
model="CoolMasterNet",
name=unit_id,
- sw_version=info["version"],
+ sw_version=coordinator.info["version"],
)
if hasattr(self, "entity_description"):
self._attr_unique_id: str = f"{unit_id}-{self.entity_description.key}"
diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py
index 4c2a09b1ce5..2b835565bae 100644
--- a/homeassistant/components/coolmaster/sensor.py
+++ b/homeassistant/components/coolmaster/sensor.py
@@ -3,26 +3,23 @@
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
-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 DATA_COORDINATOR, DATA_INFO, DOMAIN
+from .coordinator import CoolmasterConfigEntry
from .entity import CoolmasterEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CoolmasterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet sensor platform."""
- info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO]
- coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
+ coordinator = config_entry.runtime_data
async_add_entities(
- CoolmasterCleanFilter(coordinator, unit_id, info)
- for unit_id in coordinator.data
+ CoolmasterCleanFilter(coordinator, unit_id) for unit_id in coordinator.data
)
diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py
index f0a14aa7951..e84a92328b2 100644
--- a/homeassistant/components/counter/__init__.py
+++ b/homeassistant/components/counter/__init__.py
@@ -16,8 +16,7 @@ from homeassistant.const import (
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py
index 001bff51991..85069b425e3 100644
--- a/homeassistant/components/cover/__init__.py
+++ b/homeassistant/components/cover/__init__.py
@@ -9,7 +9,7 @@ import functools as ft
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -300,7 +300,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> CoverEntityFeature:
"""Flag supported features."""
if (features := self._attr_supported_features) is not None:
- if type(features) is int: # noqa: E721
+ if type(features) is int:
new_features = CoverEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py
index acef2cde4d8..a982e99776b 100644
--- a/homeassistant/components/cover/device_action.py
+++ b/homeassistant/components/cover/device_action.py
@@ -20,8 +20,7 @@ from homeassistant.const import (
SERVICE_STOP_COVER,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py
index b6fdc0a8889..3b2682d4e32 100644
--- a/homeassistant/components/cppm_tracker/device_tracker.py
+++ b/homeassistant/components/cppm_tracker/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_API_KEY, CONF_CLIENT_ID, CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
SCAN_INTERVAL = timedelta(seconds=120)
diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py
index e1443eb9516..8f5739f9172 100644
--- a/homeassistant/components/crownstone/__init__.py
+++ b/homeassistant/components/crownstone/__init__.py
@@ -2,25 +2,42 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .entry_manager import CrownstoneEntryManager
+from .const import PLATFORMS
+from .entry_manager import CrownstoneConfigEntry, CrownstoneEntryManager
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: CrownstoneConfigEntry) -> bool:
"""Initiate setup for a Crownstone config entry."""
manager = CrownstoneEntryManager(hass, entry)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager
+ if not await manager.async_setup():
+ return False
- return await manager.async_setup()
+ entry.runtime_data = manager
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ # HA specific listeners
+ entry.async_on_unload(entry.add_update_listener(async_update_listener))
+ entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.on_shutdown)
+ )
+
+ return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: CrownstoneConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload()
- if len(hass.data[DOMAIN]) == 0:
- hass.data.pop(DOMAIN)
- return unload_ok
+ entry.runtime_data.async_unload()
+
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_update_listener(
+ hass: HomeAssistant, entry: CrownstoneConfigEntry
+) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py
index 2a96098421a..5f5af4f51a4 100644
--- a/homeassistant/components/crownstone/config_flow.py
+++ b/homeassistant/components/crownstone/config_flow.py
@@ -16,7 +16,6 @@ import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
- ConfigEntry,
ConfigEntryBaseFlow,
ConfigFlow,
ConfigFlowResult,
@@ -37,6 +36,7 @@ from .const import (
MANUAL_PATH,
REFRESH_LIST,
)
+from .entry_manager import CrownstoneConfigEntry
from .helpers import list_ports_as_str
CONFIG_FLOW = "config_flow"
@@ -140,7 +140,7 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain=
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: CrownstoneConfigEntry,
) -> CrownstoneOptionsFlowHandler:
"""Return the Crownstone options."""
return CrownstoneOptionsFlowHandler(config_entry)
@@ -210,7 +210,9 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain=
class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
"""Handle Crownstone options."""
- def __init__(self, config_entry: ConfigEntry) -> None:
+ config_entry: CrownstoneConfigEntry
+
+ def __init__(self, config_entry: CrownstoneConfigEntry) -> None:
"""Initialize Crownstone options."""
super().__init__(OPTIONS_FLOW, self.async_create_new_entry)
self.options = config_entry.options.copy()
@@ -219,9 +221,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage Crownstone options."""
- self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][
- self.config_entry.entry_id
- ].cloud
+ self.cloud = self.config_entry.runtime_data.cloud
spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data}
usb_path = self.config_entry.options.get(CONF_USB_PATH)
diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py
index efee05a19c8..e414e3c7055 100644
--- a/homeassistant/components/crownstone/entry_manager.py
+++ b/homeassistant/components/crownstone/entry_manager.py
@@ -16,7 +16,7 @@ from crownstone_uart.Exceptions import UartException
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
@@ -26,7 +26,6 @@ from .const import (
CONF_USB_PATH,
CONF_USB_SPHERE,
DOMAIN,
- PLATFORMS,
PROJECT_NAME,
SSE_LISTENERS,
UART_LISTENERS,
@@ -36,6 +35,8 @@ from .listeners import setup_sse_listeners, setup_uart_listeners
_LOGGER = logging.getLogger(__name__)
+type CrownstoneConfigEntry = ConfigEntry[CrownstoneEntryManager]
+
class CrownstoneEntryManager:
"""Manage a Crownstone config entry."""
@@ -44,7 +45,9 @@ class CrownstoneEntryManager:
cloud: CrownstoneCloud
sse: CrownstoneSSEAsync
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry: CrownstoneConfigEntry
+ ) -> None:
"""Initialize the hub."""
self.hass = hass
self.config_entry = config_entry
@@ -100,18 +103,6 @@ class CrownstoneEntryManager:
# Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple
self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE]
- await self.hass.config_entries.async_forward_entry_setups(
- self.config_entry, PLATFORMS
- )
-
- # HA specific listeners
- self.config_entry.async_on_unload(
- self.config_entry.add_update_listener(_async_update_listener)
- )
- self.config_entry.async_on_unload(
- self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown)
- )
-
return True
async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None:
@@ -161,11 +152,12 @@ class CrownstoneEntryManager:
setup_uart_listeners(self)
- async def async_unload(self) -> bool:
+ @callback
+ def async_unload(self) -> None:
"""Unload the current config entry."""
# Authentication failed
if self.cloud.cloud_data is None:
- return True
+ return
self.sse.close_client()
for sse_unsub in self.listeners[SSE_LISTENERS]:
@@ -176,23 +168,9 @@ class CrownstoneEntryManager:
for subscription_id in self.listeners[UART_LISTENERS]:
UartEventBus.unsubscribe(subscription_id)
- unload_ok = await self.hass.config_entries.async_unload_platforms(
- self.config_entry, PLATFORMS
- )
-
- if unload_ok:
- self.hass.data[DOMAIN].pop(self.config_entry.entry_id)
-
- return unload_ok
-
@callback
def on_shutdown(self, _: Event) -> None:
"""Close all IO connections."""
self.sse.close_client()
if self.uart:
self.uart.stop()
-
-
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py
index 0dc86ea5f36..4da8bc8dbe7 100644
--- a/homeassistant/components/crownstone/helpers.py
+++ b/homeassistant/components/crownstone/helpers.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Sequence
import os
from serial.tools.list_ports_common import ListPortInfo
@@ -12,7 +13,7 @@ from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
def list_ports_as_str(
- serial_ports: list[ListPortInfo], no_usb_option: bool = True
+ serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
) -> list[str]:
"""Represent currently available serial ports as string.
diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py
index 16faa3a36d2..70b7631fe6b 100644
--- a/homeassistant/components/crownstone/light.py
+++ b/homeassistant/components/crownstone/light.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from functools import partial
-from typing import TYPE_CHECKING, Any
+from typing import Any
from crownstone_cloud.cloud_models.crownstones import Crownstone
from crownstone_cloud.const import DIMMING_ABILITY
@@ -11,7 +11,6 @@ from crownstone_cloud.exceptions import CrownstoneAbilityError
from crownstone_uart import CrownstoneUart
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -20,24 +19,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CROWNSTONE_INCLUDE_TYPES,
CROWNSTONE_SUFFIX,
- DOMAIN,
SIG_CROWNSTONE_STATE_UPDATE,
SIG_UART_STATE_CHANGE,
)
from .entity import CrownstoneEntity
+from .entry_manager import CrownstoneConfigEntry
from .helpers import map_from_to
-if TYPE_CHECKING:
- from .entry_manager import CrownstoneEntryManager
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: CrownstoneConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up crownstones from a config entry."""
- manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id]
+ manager = config_entry.runtime_data
entities: list[CrownstoneLightEntity] = []
diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py
index 7f45e99f93d..701bad3f104 100644
--- a/homeassistant/components/cups/sensor.py
+++ b/homeassistant/components/cups/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py
index 01dec10efe0..7c985b12ba4 100644
--- a/homeassistant/components/currencylayer/sensor.py
+++ b/homeassistant/components/currencylayer/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py
index c58578071ee..0eaffa39ee9 100644
--- a/homeassistant/components/daikin/__init__.py
+++ b/homeassistant/components/daikin/__init__.py
@@ -9,7 +9,6 @@ from aiohttp import ClientConnectionError
from pydaikin.daikin_base import Appliance
from pydaikin.factory import DaikinFactory
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
@@ -23,8 +22,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
-from .const import DOMAIN, KEY_MAC, TIMEOUT
-from .coordinator import DaikinCoordinator
+from .const import KEY_MAC, TIMEOUT
+from .coordinator import DaikinConfigEntry, DaikinCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -32,7 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bool:
"""Establish connection with Daikin."""
conf = entry.data
# For backwards compat, set unique ID
@@ -58,29 +57,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("ClientConnectionError to %s", host)
raise ConfigEntryNotReady from err
- coordinator = DaikinCoordinator(hass, device)
+ coordinator = DaikinCoordinator(hass, entry, device)
await coordinator.async_config_entry_first_refresh()
await async_migrate_unique_id(hass, entry, device)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
- if not hass.data[DOMAIN]:
- hass.data.pop(DOMAIN)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_unique_id(
- hass: HomeAssistant, config_entry: ConfigEntry, device: Appliance
+ hass: HomeAssistant, config_entry: DaikinConfigEntry, device: Appliance
) -> None:
"""Migrate old entry."""
dev_reg = dr.async_get(hass)
diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py
index 751683656f2..06ee0a03860 100644
--- a/homeassistant/components/daikin/climate.py
+++ b/homeassistant/components/daikin/climate.py
@@ -19,12 +19,10 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import DOMAIN as DAIKIN_DOMAIN
from .const import (
ATTR_INSIDE_TEMPERATURE,
ATTR_OUTSIDE_TEMPERATURE,
@@ -32,7 +30,7 @@ from .const import (
ATTR_STATE_ON,
ATTR_TARGET_TEMPERATURE,
)
-from .coordinator import DaikinCoordinator
+from .coordinator import DaikinConfigEntry, DaikinCoordinator
from .entity import DaikinEntity
_LOGGER = logging.getLogger(__name__)
@@ -83,10 +81,12 @@ DAIKIN_ATTR_ADVANCED = "adv"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: DaikinConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Daikin climate based on config_entry."""
- daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id)
+ daikin_api = entry.runtime_data
async_add_entities([DaikinClimate(daikin_api)])
diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py
index 5956d31c5fb..cc25a88ae39 100644
--- a/homeassistant/components/daikin/config_flow.py
+++ b/homeassistant/components/daikin/config_flow.py
@@ -14,10 +14,10 @@ from pydaikin.exceptions import DaikinException
from pydaikin.factory import DaikinFactory
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, KEY_MAC, TIMEOUT
@@ -142,7 +142,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a discovered Daikin device."""
_LOGGER.debug("Zeroconf user_input: %s", discovery_info)
diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py
index 35d998b4ba2..8e1713af5b2 100644
--- a/homeassistant/components/daikin/coordinator.py
+++ b/homeassistant/components/daikin/coordinator.py
@@ -5,6 +5,7 @@ import logging
from pydaikin.daikin_base import Appliance
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -12,15 +13,20 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+type DaikinConfigEntry = ConfigEntry[DaikinCoordinator]
+
class DaikinCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching Daikin data."""
- def __init__(self, hass: HomeAssistant, device: Appliance) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry: DaikinConfigEntry, device: Appliance
+ ) -> None:
"""Initialize global Daikin data updater."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=device.values.get("name", DOMAIN),
update_interval=timedelta(seconds=60),
)
diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py
index d2d6ef02fc3..982aac1f3f2 100644
--- a/homeassistant/components/daikin/sensor.py
+++ b/homeassistant/components/daikin/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UnitOfEnergy,
@@ -24,7 +23,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import DOMAIN as DAIKIN_DOMAIN
from .const import (
ATTR_COMPRESSOR_FREQUENCY,
ATTR_COOL_ENERGY,
@@ -37,7 +35,7 @@ from .const import (
ATTR_TOTAL_ENERGY_TODAY,
ATTR_TOTAL_POWER,
)
-from .coordinator import DaikinCoordinator
+from .coordinator import DaikinConfigEntry, DaikinCoordinator
from .entity import DaikinEntity
@@ -134,10 +132,12 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: DaikinConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Daikin climate based on config_entry."""
- daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id)
+ daikin_api = entry.runtime_data
sensors = [ATTR_INSIDE_TEMPERATURE]
if daikin_api.device.support_outside_temperature:
sensors.append(ATTR_OUTSIDE_TEMPERATURE)
diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py
index 669048ac45e..8a3a15d367f 100644
--- a/homeassistant/components/daikin/switch.py
+++ b/homeassistant/components/daikin/switch.py
@@ -5,12 +5,10 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import DOMAIN
-from .coordinator import DaikinCoordinator
+from .coordinator import DaikinConfigEntry, DaikinCoordinator
from .entity import DaikinEntity
DAIKIN_ATTR_ADVANCED = "adv"
@@ -19,10 +17,12 @@ DAIKIN_ATTR_MODE = "mode"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: DaikinConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Daikin climate based on config_entry."""
- daikin_api: DaikinCoordinator = hass.data[DOMAIN][entry.entry_id]
+ daikin_api = entry.runtime_data
switches: list[SwitchEntity] = []
if zones := daikin_api.device.zones:
switches.extend(
diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py
index 5e4880705d5..d7c16d5da09 100644
--- a/homeassistant/components/danfoss_air/__init__.py
+++ b/homeassistant/components/danfoss_air/__init__.py
@@ -9,8 +9,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py
index 2d550e48e2f..fa852399b09 100644
--- a/homeassistant/components/datadog/__init__.py
+++ b/homeassistant/components/datadog/__init__.py
@@ -14,8 +14,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py
index 622ec574542..43ce6a9b4c1 100644
--- a/homeassistant/components/date/__init__.py
+++ b/homeassistant/components/date/__init__.py
@@ -6,7 +6,7 @@ from datetime import date, timedelta
import logging
from typing import final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py
index 8aef34ddcbd..53f85992abc 100644
--- a/homeassistant/components/datetime/__init__.py
+++ b/homeassistant/components/datetime/__init__.py
@@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta
import logging
from typing import final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py
index d72496e4d1e..e93b7e14e05 100644
--- a/homeassistant/components/ddwrt/device_tracker.py
+++ b/homeassistant/components/ddwrt/device_tracker.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/debugpy/__init__.py b/homeassistant/components/debugpy/__init__.py
index 5caf517a483..cef98211d9e 100644
--- a/homeassistant/components/debugpy/__init__.py
+++ b/homeassistant/components/debugpy/__init__.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py
index 8007f3217d5..7de091c1292 100644
--- a/homeassistant/components/deconz/__init__.py
+++ b/homeassistant/components/deconz/__init__.py
@@ -9,15 +9,17 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-from .config_flow import get_master_hub
from .const import CONF_MASTER_GATEWAY, DOMAIN, PLATFORMS
from .deconz_event import async_setup_events, async_unload_events
from .errors import AuthenticationRequired, CannotConnect
from .hub import DeconzHub, get_deconz_api
from .services import async_setup_services
+from .util import get_master_hub
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+type DeconzConfigEntry = ConfigEntry[DeconzHub]
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up services."""
@@ -25,14 +27,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: DeconzConfigEntry
+) -> bool:
"""Set up a deCONZ bridge for a config entry.
Load config, group, light and sensor data for server information.
Start websocket for push notification of state changes from deCONZ.
"""
- hass.data.setdefault(DOMAIN, {})
-
if not config_entry.options:
await async_update_master_hub(hass, config_entry)
@@ -43,10 +45,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
except AuthenticationRequired as err:
raise ConfigEntryAuthFailed from err
- hub = hass.data[DOMAIN][config_entry.entry_id] = DeconzHub(hass, config_entry, api)
+ hub = DeconzHub(hass, config_entry, api)
+ config_entry.runtime_data = hub
await hub.async_update_device_registry()
- config_entry.add_update_listener(hub.async_config_entry_updated)
+ config_entry.async_on_unload(
+ config_entry.add_update_listener(hub.async_config_entry_updated)
+ )
await async_setup_events(hub)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@@ -60,32 +65,44 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: DeconzConfigEntry
+) -> bool:
"""Unload deCONZ config entry."""
- hub: DeconzHub = hass.data[DOMAIN].pop(config_entry.entry_id)
+ hub = config_entry.runtime_data
async_unload_events(hub)
- if hass.data[DOMAIN] and hub.master:
- await async_update_master_hub(hass, config_entry)
- new_master_hub = next(iter(hass.data[DOMAIN].values()))
- await async_update_master_hub(hass, new_master_hub.config_entry)
+ other_loaded_entries: list[DeconzConfigEntry] = [
+ e
+ for e in hass.config_entries.async_loaded_entries(DOMAIN)
+ # exclude the config entry being unloaded
+ if e.entry_id != config_entry.entry_id
+ ]
+ if other_loaded_entries and hub.master:
+ await async_update_master_hub(hass, config_entry, master=False)
+ new_master_hub = next(iter(other_loaded_entries)).runtime_data
+ await async_update_master_hub(hass, new_master_hub.config_entry, master=True)
return await hub.async_reset()
async def async_update_master_hub(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant,
+ config_entry: DeconzConfigEntry,
+ *,
+ master: bool | None = None,
) -> None:
"""Update master hub boolean.
Called by setup_entry and unload_entry.
Makes sure there is always one master available.
"""
- try:
- master_hub = get_master_hub(hass)
- master = master_hub.config_entry == config_entry
- except ValueError:
- master = True
+ if master is None:
+ try:
+ master_hub = get_master_hub(hass)
+ master = master_hub.config_entry == config_entry
+ except ValueError:
+ master = True
options = {**config_entry.options, CONF_MASTER_GATEWAY: master}
diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py
index 678e441a7a9..94f4cd1ddd6 100644
--- a/homeassistant/components/deconz/alarm_control_panel.py
+++ b/homeassistant/components/deconz/alarm_control_panel.py
@@ -16,10 +16,10 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -47,11 +47,11 @@ def get_alarm_system_id_for_unique_id(hub: DeconzHub, unique_id: str) -> str | N
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ alarm control panel devices."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[ALARM_CONTROl_PANEL_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py
index a5496d3bc10..e3b0fc2f2c0 100644
--- a/homeassistant/components/deconz/binary_sensor.py
+++ b/homeassistant/components/deconz/binary_sensor.py
@@ -23,11 +23,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .const import ATTR_DARK, ATTR_ON
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -160,11 +160,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ binary sensor."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[BINARY_SENSOR_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py
index ecf28b5e22c..9fea1d02ab8 100644
--- a/homeassistant/components/deconz/button.py
+++ b/homeassistant/components/deconz/button.py
@@ -14,11 +14,11 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice, DeconzSceneMixin
from .hub import DeconzHub
@@ -46,11 +46,11 @@ ENTITY_DESCRIPTIONS = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ button entity."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[BUTTON_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py
index 690f943379d..aa274e6c0c1 100644
--- a/homeassistant/components/deconz/climate.py
+++ b/homeassistant/components/deconz/climate.py
@@ -28,11 +28,11 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -76,11 +76,11 @@ DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.item
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ climate devices."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[CLIMATE_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py
index ed54701f656..41e45d53c76 100644
--- a/homeassistant/components/deconz/config_flow.py
+++ b/homeassistant/components/deconz/config_flow.py
@@ -19,7 +19,6 @@ from pydeconz.utils import (
)
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
SOURCE_HASSIO,
ConfigEntry,
@@ -28,9 +27,10 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo
from .const import (
CONF_ALLOW_CLIP_SENSOR,
@@ -51,15 +51,6 @@ CONF_SERIAL = "serial"
CONF_MANUAL_INPUT = "Manually define gateway"
-@callback
-def get_master_hub(hass: HomeAssistant) -> DeconzHub:
- """Return the gateway which is marked as master."""
- for hub in hass.data[DOMAIN].values():
- if hub.master:
- return cast(DeconzHub, hub)
- raise ValueError
-
-
class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a deCONZ config flow."""
@@ -220,13 +211,13 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_link()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered deCONZ bridge."""
if LOGGER.isEnabledFor(logging.DEBUG):
LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info))
- self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL])
+ self.bridge_id = normalize_bridge_id(discovery_info.upnp[ATTR_UPNP_SERIAL])
parsed_url = urlparse(discovery_info.ssdp_location)
entry = await self.async_set_unique_id(self.bridge_id)
diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py
index 030c4b12709..6dee00248ff 100644
--- a/homeassistant/components/deconz/cover.py
+++ b/homeassistant/components/deconz/cover.py
@@ -17,10 +17,10 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -33,11 +33,11 @@ DECONZ_TYPE_TO_DEVICE_CLASS = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up covers for deCONZ component."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[COVER_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py
index 2aeeece3ac5..158ac391b9b 100644
--- a/homeassistant/components/deconz/device_trigger.py
+++ b/homeassistant/components/deconz/device_trigger.py
@@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-from . import DOMAIN
+from . import DOMAIN, DeconzConfigEntry
from .deconz_event import (
CONF_DECONZ_EVENT,
CONF_GESTURE,
@@ -31,7 +31,6 @@ from .deconz_event import (
DeconzPresenceEvent,
DeconzRelativeRotaryEvent,
)
-from .hub import DeconzHub
CONF_SUBTYPE = "subtype"
@@ -684,9 +683,9 @@ def _get_deconz_event_from_device(
device: dr.DeviceEntry,
) -> DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent | DeconzRelativeRotaryEvent:
"""Resolve deconz event from device."""
- hubs: dict[str, DeconzHub] = hass.data.get(DOMAIN, {})
- for hub in hubs.values():
- for deconz_event in hub.events:
+ entry: DeconzConfigEntry
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN):
+ for deconz_event in entry.runtime_data.events:
if device.id == deconz_event.device_id:
return deconz_event
diff --git a/homeassistant/components/deconz/diagnostics.py b/homeassistant/components/deconz/diagnostics.py
index fcd5dec120f..284b538d1dd 100644
--- a/homeassistant/components/deconz/diagnostics.py
+++ b/homeassistant/components/deconz/diagnostics.py
@@ -5,21 +5,20 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
-from .hub import DeconzHub
+from . import DeconzConfigEntry
REDACT_CONFIG = {CONF_API_KEY, CONF_UNIQUE_ID}
REDACT_DECONZ_CONFIG = {"bridgeid", "mac", "panid"}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: DeconzConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
diag: dict[str, Any] = {}
diag["config"] = async_redact_data(config_entry.as_dict(), REDACT_CONFIG)
diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py
index 26e4d3328b8..aec078f771f 100644
--- a/homeassistant/components/deconz/fan.py
+++ b/homeassistant/components/deconz/fan.py
@@ -12,7 +12,6 @@ from homeassistant.components.fan import (
FanEntity,
FanEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
@@ -20,6 +19,7 @@ from homeassistant.util.percentage import (
percentage_to_ordered_list_item,
)
+from . import DeconzConfigEntry
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -33,11 +33,11 @@ ORDERED_NAMED_FAN_SPEEDS: list[LightFanSpeed] = [
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up fans for deCONZ component."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[FAN_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/hub/api.py b/homeassistant/components/deconz/hub/api.py
index 916c34672d8..c00a2178eb0 100644
--- a/homeassistant/components/deconz/hub/api.py
+++ b/homeassistant/components/deconz/hub/api.py
@@ -3,10 +3,10 @@
from __future__ import annotations
import asyncio
+from typing import TYPE_CHECKING
from pydeconz import DeconzSession, errors
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
@@ -14,9 +14,12 @@ from ..const import LOGGER
from ..errors import AuthenticationRequired, CannotConnect
from .config import DeconzConfig
+if TYPE_CHECKING:
+ from .. import DeconzConfigEntry
+
async def get_deconz_api(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: DeconzConfigEntry
) -> DeconzSession:
"""Create a gateway object and verify configuration."""
session = aiohttp_client.async_get_clientsession(hass)
diff --git a/homeassistant/components/deconz/hub/config.py b/homeassistant/components/deconz/hub/config.py
index 06d2dc10542..5acbe816833 100644
--- a/homeassistant/components/deconz/hub/config.py
+++ b/homeassistant/components/deconz/hub/config.py
@@ -3,9 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Self
+from typing import TYPE_CHECKING, Self
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
from ..const import (
@@ -17,12 +16,15 @@ from ..const import (
DEFAULT_ALLOW_NEW_DEVICES,
)
+if TYPE_CHECKING:
+ from .. import DeconzConfigEntry
+
@dataclass
class DeconzConfig:
"""Represent a deCONZ config entry."""
- entry: ConfigEntry
+ entry: DeconzConfigEntry
host: str
port: int
@@ -33,7 +35,7 @@ class DeconzConfig:
allow_new_devices: bool
@classmethod
- def from_config_entry(cls, config_entry: ConfigEntry) -> Self:
+ def from_config_entry(cls, config_entry: DeconzConfigEntry) -> Self:
"""Create object from config entry."""
config = config_entry.data
options = config_entry.options
diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py
index ff958bbda50..3020d624f97 100644
--- a/homeassistant/components/deconz/hub/hub.py
+++ b/homeassistant/components/deconz/hub/hub.py
@@ -11,7 +11,7 @@ from pydeconz.interfaces.api_handlers import APIHandler, GroupedAPIHandler
from pydeconz.interfaces.groups import GroupHandler
from pydeconz.models.event import EventType
-from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry
+from homeassistant.config_entries import SOURCE_HASSIO
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
@@ -26,6 +26,7 @@ from ..const import (
from .config import DeconzConfig
if TYPE_CHECKING:
+ from .. import DeconzConfigEntry
from ..deconz_event import (
DeconzAlarmEvent,
DeconzEvent,
@@ -67,7 +68,7 @@ class DeconzHub:
"""Manages a single deCONZ gateway."""
def __init__(
- self, hass: HomeAssistant, config_entry: ConfigEntry, api: DeconzSession
+ self, hass: HomeAssistant, config_entry: DeconzConfigEntry, api: DeconzSession
) -> None:
"""Initialize the system."""
self.hass = hass
@@ -94,12 +95,6 @@ class DeconzHub:
self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set()
self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set()
- @callback
- @staticmethod
- def get_hub(hass: HomeAssistant, config_entry: ConfigEntry) -> DeconzHub:
- """Return hub with a matching config entry ID."""
- return cast(DeconzHub, hass.data[DECONZ_DOMAIN][config_entry.entry_id])
-
@property
def bridgeid(self) -> str:
"""Return the unique identifier of the gateway."""
@@ -208,7 +203,7 @@ class DeconzHub:
@staticmethod
async def async_config_entry_updated(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: DeconzConfigEntry
) -> None:
"""Handle signals of config entry being updated.
@@ -217,11 +212,7 @@ class DeconzHub:
Causes for this is either discovery updating host address or
config entry options changing.
"""
- if config_entry.entry_id not in hass.data[DECONZ_DOMAIN]:
- # A race condition can occur if multiple config entries are
- # unloaded in parallel
- return
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
previous_config = hub.config
hub.config = DeconzConfig.from_config_entry(config_entry)
if previous_config.host != hub.config.host:
diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py
index d82c05f14eb..72ba7035c8e 100644
--- a/homeassistant/components/deconz/light.py
+++ b/homeassistant/components/deconz/light.py
@@ -28,7 +28,6 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -38,6 +37,7 @@ from homeassistant.util.color import (
color_temperature_mired_to_kelvin,
)
+from . import DeconzConfigEntry
from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -141,11 +141,11 @@ def update_color_state(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ lights and groups from a config entry."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[LIGHT_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py
index 50375e99778..e5e2faf1d57 100644
--- a/homeassistant/components/deconz/lock.py
+++ b/homeassistant/components/deconz/lock.py
@@ -9,21 +9,20 @@ from pydeconz.models.light.lock import Lock
from pydeconz.models.sensor.door_lock import DoorLock
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice
-from .hub import DeconzHub
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up locks for deCONZ component."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[LOCK_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py
index 3ef14eca657..28dfb603d8b 100644
--- a/homeassistant/components/deconz/logbook.py
+++ b/homeassistant/components/deconz/logbook.py
@@ -7,7 +7,7 @@ from collections.abc import Callable
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT, CONF_ID
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN
from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT
diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py
index 53461960573..9de86c1c79b 100644
--- a/homeassistant/components/deconz/number.py
+++ b/homeassistant/components/deconz/number.py
@@ -17,11 +17,11 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -69,11 +69,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ number entity."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[NUMBER_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py
index 70b9f3f21b5..3f29b12b05f 100644
--- a/homeassistant/components/deconz/scene.py
+++ b/homeassistant/components/deconz/scene.py
@@ -7,21 +7,20 @@ from typing import Any
from pydeconz.models.event import EventType
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzSceneMixin
-from .hub import DeconzHub
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up scenes for deCONZ integration."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[SCENE_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py
index cbd96a4faf9..a3109a278fc 100644
--- a/homeassistant/components/deconz/select.py
+++ b/homeassistant/components/deconz/select.py
@@ -12,13 +12,12 @@ from pydeconz.models.sensor.presence import (
)
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice
-from .hub import DeconzHub
SENSITIVITY_TO_DECONZ = {
"High": PresenceConfigSensitivity.HIGH.value,
@@ -30,11 +29,11 @@ DECONZ_TO_SENSITIVITY = {value: key for key, value in SENSITIVITY_TO_DECONZ.item
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ button entity."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[SELECT_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py
index 241ba015c67..3003fb1008d 100644
--- a/homeassistant/components/deconz/sensor.py
+++ b/homeassistant/components/deconz/sensor.py
@@ -34,7 +34,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
ATTR_VOLTAGE,
@@ -53,8 +52,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
+from . import DeconzConfigEntry
from .const import ATTR_DARK, ATTR_ON
from .entity import DeconzDevice
from .hub import DeconzHub
@@ -331,11 +331,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the deCONZ sensors."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[SENSOR_DOMAIN] = set()
known_device_entities: dict[str, set[str]] = {
diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py
index e10195d86bc..1f032f3866a 100644
--- a/homeassistant/components/deconz/services.py
+++ b/homeassistant/components/deconz/services.py
@@ -1,5 +1,7 @@
"""deCONZ services."""
+from typing import TYPE_CHECKING
+
from pydeconz.utils import normalize_bridge_id
import voluptuous as vol
@@ -12,9 +14,13 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.read_only_dict import ReadOnlyDict
-from .config_flow import get_master_hub
from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER
from .hub import DeconzHub
+from .util import get_master_hub
+
+if TYPE_CHECKING:
+ from . import DeconzConfigEntry
+
DECONZ_SERVICES = "deconz_services"
@@ -65,7 +71,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
found_hub = False
bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID])
- for possible_hub in hass.data[DOMAIN].values():
+ entry: DeconzConfigEntry
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN):
+ possible_hub = entry.runtime_data
if possible_hub.bridgeid == bridge_id:
hub = possible_hub
found_hub = True
diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py
index 982a0bd1b9e..28b606e30ba 100644
--- a/homeassistant/components/deconz/siren.py
+++ b/homeassistant/components/deconz/siren.py
@@ -13,21 +13,20 @@ from homeassistant.components.siren import (
SirenEntity,
SirenEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .entity import DeconzDevice
-from .hub import DeconzHub
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sirens for deCONZ component."""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[SIREN_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py
index c79cd7b28db..cd28871e35b 100644
--- a/homeassistant/components/deconz/switch.py
+++ b/homeassistant/components/deconz/switch.py
@@ -8,25 +8,24 @@ from pydeconz.models.event import EventType
from pydeconz.models.light.light import Light
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DeconzConfigEntry
from .const import POWER_PLUGS
from .entity import DeconzDevice
-from .hub import DeconzHub
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DeconzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches for deCONZ component.
Switches are based on the same device class as lights in deCONZ.
"""
- hub = DeconzHub.get_hub(hass, config_entry)
+ hub = config_entry.runtime_data
hub.entities[SWITCH_DOMAIN] = set()
@callback
diff --git a/homeassistant/components/deconz/util.py b/homeassistant/components/deconz/util.py
index 7c44280200d..c4dc9df08ce 100644
--- a/homeassistant/components/deconz/util.py
+++ b/homeassistant/components/deconz/util.py
@@ -2,9 +2,30 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
+
+from homeassistant.core import HomeAssistant, callback
+
+from .const import DOMAIN
+from .hub import DeconzHub
+
+if TYPE_CHECKING:
+ from . import DeconzConfigEntry
+
def serial_from_unique_id(unique_id: str | None) -> str | None:
"""Get a device serial number from a unique ID, if possible."""
if not unique_id or unique_id.count(":") != 7:
return None
return unique_id.partition("-")[0]
+
+
+@callback
+def get_master_hub(hass: HomeAssistant) -> DeconzHub:
+ """Return the gateway which is marked as master."""
+ entry: DeconzConfigEntry
+ hub: DeconzHub
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN):
+ if (hub := entry.runtime_data).master:
+ return hub
+ raise ValueError
diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py
index cef7b98a2c1..a7d14b83aca 100644
--- a/homeassistant/components/decora/light.py
+++ b/homeassistant/components/decora/light.py
@@ -21,7 +21,7 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py
index 63ab5c2bf02..9ad1d9ced04 100644
--- a/homeassistant/components/decora_wifi/light.py
+++ b/homeassistant/components/decora_wifi/light.py
@@ -22,7 +22,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/decorquip/__init__.py b/homeassistant/components/decorquip/__init__.py
new file mode 100644
index 00000000000..2fd6dc0efce
--- /dev/null
+++ b/homeassistant/components/decorquip/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Decorquip."""
diff --git a/homeassistant/components/decorquip/manifest.json b/homeassistant/components/decorquip/manifest.json
new file mode 100644
index 00000000000..769b0bf9441
--- /dev/null
+++ b/homeassistant/components/decorquip/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "decorquip",
+ "name": "Decorquip Dream",
+ "integration_type": "virtual",
+ "supported_by": "motion_blinds"
+}
diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py
index 017a4c5b2fa..7f94f272c0d 100644
--- a/homeassistant/components/delijn/sensor.py
+++ b/homeassistant/components/delijn/sensor.py
@@ -16,8 +16,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py
index f4608b37006..9b07ae9c875 100644
--- a/homeassistant/components/deluge/__init__.py
+++ b/homeassistant/components/deluge/__init__.py
@@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bo
await hass.async_add_executor_job(api.connect)
except (ConnectionRefusedError, TimeoutError, SSLError) as ex:
raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex
- except Exception as ex: # noqa: BLE001
+ except Exception as ex:
if type(ex).__name__ == "BadLoginError":
raise ConfigEntryAuthFailed(
"Credentials for Deluge client are not valid"
diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py
index d58f23464d1..19afe26e8f9 100644
--- a/homeassistant/components/deluge/config_flow.py
+++ b/homeassistant/components/deluge/config_flow.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONF_WEB_PORT,
diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py
index d088dfb140b..9314fc211de 100644
--- a/homeassistant/components/demo/__init__.py
+++ b/homeassistant/components/demo/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
-from homeassistant import config_entries, setup
+from homeassistant import config_entries, core as ha, setup
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -13,7 +13,6 @@ from homeassistant.const import (
Platform,
UnitOfSoundPressure,
)
-import homeassistant.core as ha
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py
index d513bc38250..4e2fa7b3460 100644
--- a/homeassistant/components/demo/calendar.py
+++ b/homeassistant/components/demo/calendar.py
@@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
async def async_setup_entry(
diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py
index 53c1678aa81..6f8ee26f511 100644
--- a/homeassistant/components/demo/config_flow.py
+++ b/homeassistant/components/demo/config_flow.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from . import DOMAIN
diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py
index 8ce77bcd615..fa3c3e3b2fc 100644
--- a/homeassistant/components/demo/media_player.py
+++ b/homeassistant/components/demo/media_player.py
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
async def async_setup_entry(
diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py
index fbc2b660efb..2468c54dde3 100644
--- a/homeassistant/components/demo/weather.py
+++ b/homeassistant/components/demo/weather.py
@@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
CONDITION_CLASSES: dict[str, list[str]] = {
ATTR_CONDITION_CLOUDY: [],
diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py
index 0a6fe18d986..9e7cebe0702 100644
--- a/homeassistant/components/denon/media_player.py
+++ b/homeassistant/components/denon/media_player.py
@@ -3,8 +3,8 @@
from __future__ import annotations
import logging
-import telnetlib # pylint: disable=deprecated-module
+import telnetlib # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.media_player import (
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py
index 98b77a994f6..da2b601317a 100644
--- a/homeassistant/components/denonavr/__init__.py
+++ b/homeassistant/components/denonavr/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.httpx_client import get_async_client
-from .config_flow import (
+from .const import (
CONF_SHOW_ALL_SOURCES,
CONF_UPDATE_AUDYSSEY,
CONF_USE_TELNET,
@@ -24,21 +24,18 @@ from .config_flow import (
DEFAULT_USE_TELNET,
DEFAULT_ZONE2,
DEFAULT_ZONE3,
- DOMAIN,
)
from .receiver import ConnectDenonAVR
-CONF_RECEIVER = "receiver"
-UNDO_UPDATE_LISTENER = "undo_update_listener"
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
+type DenonavrConfigEntry = ConfigEntry[DenonAVR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> bool:
"""Set up the denonavr components from a config entry."""
- hass.data.setdefault(DOMAIN, {})
-
# Connect to receiver
connect_denonavr = ConnectDenonAVR(
entry.data[CONF_HOST],
@@ -56,12 +53,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from ex
receiver = connect_denonavr.receiver
- undo_listener = entry.add_update_listener(update_listener)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
- hass.data[DOMAIN][entry.entry_id] = {
- CONF_RECEIVER: receiver,
- UNDO_UPDATE_LISTENER: undo_listener,
- }
+ entry.runtime_data = receiver
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
use_telnet = entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET)
@@ -79,18 +73,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: DenonavrConfigEntry
+) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if config_entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET):
- receiver: DenonAVR = hass.data[DOMAIN][config_entry.entry_id][CONF_RECEIVER]
+ receiver = config_entry.runtime_data
await receiver.async_telnet_disconnect()
- hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]()
-
# Remove zone2 and zone3 entities if needed
entity_registry = er.async_get(hass)
entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
@@ -105,12 +99,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
entity_registry.async_remove(entry.entity_id)
_LOGGER.debug("Removing zone3 from DenonAvr")
- if unload_ok:
- hass.data[DOMAIN].pop(config_entry.entry_id)
-
return unload_ok
-async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def update_listener(
+ hass: HomeAssistant, config_entry: DenonavrConfigEntry
+) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py
index 9ff05411588..930d0e009ac 100644
--- a/homeassistant/components/denonavr/config_flow.py
+++ b/homeassistant/components/denonavr/config_flow.py
@@ -10,40 +10,42 @@ import denonavr
from denonavr.exceptions import AvrNetworkError, AvrTimoutError
import voluptuous as vol
-from homeassistant.components import ssdp
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.core import callback
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
+from . import DenonavrConfigEntry
+from .const import (
+ CONF_MANUFACTURER,
+ CONF_SERIAL_NUMBER,
+ CONF_SHOW_ALL_SOURCES,
+ CONF_UPDATE_AUDYSSEY,
+ CONF_USE_TELNET,
+ CONF_ZONE2,
+ CONF_ZONE3,
+ DEFAULT_SHOW_SOURCES,
+ DEFAULT_TIMEOUT,
+ DEFAULT_UPDATE_AUDYSSEY,
+ DEFAULT_USE_TELNET,
+ DEFAULT_ZONE2,
+ DEFAULT_ZONE3,
+ DOMAIN,
+)
from .receiver import ConnectDenonAVR
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "denonavr"
-
SUPPORTED_MANUFACTURERS = ["Denon", "DENON", "DENON PROFESSIONAL", "Marantz"]
IGNORED_MODELS = ["HEOS 1", "HEOS 3", "HEOS 5", "HEOS 7"]
-CONF_SHOW_ALL_SOURCES = "show_all_sources"
-CONF_ZONE2 = "zone2"
-CONF_ZONE3 = "zone3"
-CONF_MANUFACTURER = "manufacturer"
-CONF_SERIAL_NUMBER = "serial_number"
-CONF_UPDATE_AUDYSSEY = "update_audyssey"
-CONF_USE_TELNET = "use_telnet"
-
-DEFAULT_SHOW_SOURCES = False
-DEFAULT_TIMEOUT = 5
-DEFAULT_ZONE2 = False
-DEFAULT_ZONE3 = False
-DEFAULT_UPDATE_AUDYSSEY = False
-DEFAULT_USE_TELNET = False
DEFAULT_USE_TELNET_NEW_INSTALL = True
CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str})
@@ -112,7 +114,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: DenonavrConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow."""
return OptionsFlowHandler()
@@ -232,7 +234,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Denon AVR.
@@ -241,22 +243,20 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN):
"""
# Filter out non-Denon AVRs#1
if (
- discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER)
+ discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER)
not in SUPPORTED_MANUFACTURERS
):
return self.async_abort(reason="not_denonavr_manufacturer")
# Check if required information is present to set the unique_id
if (
- ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp
- or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp
+ ATTR_UPNP_MODEL_NAME not in discovery_info.upnp
+ or ATTR_UPNP_SERIAL not in discovery_info.upnp
):
return self.async_abort(reason="not_denonavr_missing")
- self.model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME].replace(
- "*", ""
- )
- self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
+ self.model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME].replace("*", "")
+ self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL]
assert discovery_info.ssdp_location is not None
self.host = urlparse(discovery_info.ssdp_location).hostname
@@ -270,9 +270,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN):
self.context.update(
{
"title_placeholders": {
- "name": discovery_info.upnp.get(
- ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host
- )
+ "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host)
}
}
)
diff --git a/homeassistant/components/denonavr/const.py b/homeassistant/components/denonavr/const.py
new file mode 100644
index 00000000000..d28044ec018
--- /dev/null
+++ b/homeassistant/components/denonavr/const.py
@@ -0,0 +1,19 @@
+"""Constants for Denon AVR."""
+
+DOMAIN = "denonavr"
+
+
+CONF_SHOW_ALL_SOURCES = "show_all_sources"
+CONF_ZONE2 = "zone2"
+CONF_ZONE3 = "zone3"
+CONF_MANUFACTURER = "manufacturer"
+CONF_SERIAL_NUMBER = "serial_number"
+CONF_UPDATE_AUDYSSEY = "update_audyssey"
+CONF_USE_TELNET = "use_telnet"
+
+DEFAULT_SHOW_SOURCES = False
+DEFAULT_TIMEOUT = 5
+DEFAULT_ZONE2 = False
+DEFAULT_ZONE3 = False
+DEFAULT_UPDATE_AUDYSSEY = False
+DEFAULT_USE_TELNET = False
diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py
index 03d1b00cfaf..818d530ddab 100644
--- a/homeassistant/components/denonavr/media_player.py
+++ b/homeassistant/components/denonavr/media_player.py
@@ -35,18 +35,16 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL
+from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import CONF_RECEIVER
-from .config_flow import (
+from . import DenonavrConfigEntry
+from .const import (
CONF_MANUFACTURER,
CONF_SERIAL_NUMBER,
- CONF_TYPE,
CONF_UPDATE_AUDYSSEY,
DEFAULT_UPDATE_AUDYSSEY,
DOMAIN,
@@ -110,13 +108,12 @@ DENON_STATE_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DenonavrConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the DenonAVR receiver from a config entry."""
entities = []
- data = hass.data[DOMAIN][config_entry.entry_id]
- receiver = data[CONF_RECEIVER]
+ receiver = config_entry.runtime_data
update_audyssey = config_entry.options.get(
CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY
)
@@ -253,7 +250,7 @@ class DenonDevice(MediaPlayerEntity):
self,
receiver: DenonAVR,
unique_id: str,
- config_entry: ConfigEntry,
+ config_entry: DenonavrConfigEntry,
update_audyssey: bool,
) -> None:
"""Initialize the device."""
diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json
index a4e07e33a6a..6c055c5932a 100644
--- a/homeassistant/components/denonavr/strings.json
+++ b/homeassistant/components/denonavr/strings.json
@@ -50,7 +50,7 @@
"services": {
"get_command": {
"name": "Get command",
- "description": "Send sa generic HTTP get command.",
+ "description": "Sends a generic HTTP get command.",
"fields": {
"command": {
"name": "Command",
diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py
index 77ce5169d8d..988da5e938b 100644
--- a/homeassistant/components/derivative/sensor.py
+++ b/homeassistant/components/derivative/sensor.py
@@ -272,7 +272,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
if elapsed_time > self._time_window:
derivative = new_derivative
else:
- derivative = Decimal(0.00)
+ derivative = Decimal("0.00")
for start, end, value in self._state_list:
weight = calculate_weight(start, end, new_state.last_updated)
derivative = derivative + (value * Decimal(weight))
diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py
index 2eccdb2a4b6..be641ad58a5 100644
--- a/homeassistant/components/devialet/__init__.py
+++ b/homeassistant/components/devialet/__init__.py
@@ -4,29 +4,28 @@ from __future__ import annotations
from devialet import DevialetApi
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
+from .coordinator import DevialetConfigEntry, DevialetCoordinator
PLATFORMS = [Platform.MEDIA_PLAYER]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool:
"""Set up Devialet from a config entry."""
session = async_get_clientsession(hass)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi(
- entry.data[CONF_HOST], session
- )
+ client = DevialetApi(entry.data[CONF_HOST], session)
+ coordinator = DevialetCoordinator(hass, entry, client)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool:
"""Unload Devialet config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py
index 41acfa4b5a7..45a00fc4073 100644
--- a/homeassistant/components/devialet/config_flow.py
+++ b/homeassistant/components/devialet/config_flow.py
@@ -8,10 +8,10 @@ from typing import Any
from devialet.devialet_api import DevialetApi
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -70,7 +70,7 @@ class DevialetFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
LOGGER.debug("Devialet device found via ZEROCONF: %s", discovery_info)
diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py
index 9cfeb797373..7b022b921f8 100644
--- a/homeassistant/components/devialet/coordinator.py
+++ b/homeassistant/components/devialet/coordinator.py
@@ -5,6 +5,7 @@ import logging
from devialet import DevialetApi
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -14,15 +15,22 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5)
+type DevialetConfigEntry = ConfigEntry[DevialetCoordinator]
+
class DevialetCoordinator(DataUpdateCoordinator[None]):
"""Devialet update coordinator."""
- def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None:
+ config_entry: DevialetConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, entry: DevialetConfigEntry, client: DevialetApi
+ ) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py
index ae887dd1c8c..75d6e7aa222 100644
--- a/homeassistant/components/devialet/diagnostics.py
+++ b/homeassistant/components/devialet/diagnostics.py
@@ -4,18 +4,13 @@ from __future__ import annotations
from typing import Any
-from devialet import DevialetApi
-
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .coordinator import DevialetConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: DevialetConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- client: DevialetApi = hass.data[DOMAIN][entry.entry_id]
-
- return await client.async_get_diagnostics()
+ return await entry.runtime_data.client.async_get_diagnostics()
diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json
index dd30f91c835..f101a325dab 100644
--- a/homeassistant/components/devialet/manifest.json
+++ b/homeassistant/components/devialet/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/devialet",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["devialet==1.4.5"],
+ "requirements": ["devialet==1.5.7"],
"zeroconf": ["_devialet-http._tcp.local."]
}
diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py
index d490e348b9c..04ec58723cf 100644
--- a/homeassistant/components/devialet/media_player.py
+++ b/homeassistant/components/devialet/media_player.py
@@ -9,7 +9,6 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
@@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SOUND_MODES
-from .coordinator import DevialetCoordinator
+from .coordinator import DevialetConfigEntry, DevialetCoordinator
SUPPORT_DEVIALET = (
MediaPlayerEntityFeature.VOLUME_SET
@@ -37,14 +36,12 @@ DEVIALET_TO_HA_FEATURE_MAP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: DevialetConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Devialet entry."""
- client = hass.data[DOMAIN][entry.entry_id]
- coordinator = DevialetCoordinator(hass, client)
- await coordinator.async_config_entry_first_refresh()
-
- async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)])
+ async_add_entities([DevialetMediaPlayerEntity(entry.runtime_data)])
class DevialetMediaPlayerEntity(
@@ -55,18 +52,18 @@ class DevialetMediaPlayerEntity(
_attr_has_entity_name = True
_attr_name = None
- def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None:
+ def __init__(self, coordinator: DevialetCoordinator) -> None:
"""Initialize the Devialet device."""
- self.coordinator = coordinator
super().__init__(coordinator)
+ entry = coordinator.config_entry
self._attr_unique_id = str(entry.unique_id)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer=MANUFACTURER,
- model=self.coordinator.client.model,
+ model=coordinator.client.model,
name=entry.data[CONF_NAME],
- sw_version=self.coordinator.client.version,
+ sw_version=coordinator.client.version,
)
@callback
@@ -122,10 +119,10 @@ class DevialetMediaPlayerEntity(
if self.coordinator.client.source_state is None:
return features
- if not self.coordinator.client.available_options:
+ if not self.coordinator.client.available_operations:
return features
- for option in self.coordinator.client.available_options:
+ for option in self.coordinator.client.available_operations:
features |= DEVIALET_TO_HA_FEATURE_MAP.get(option, 0)
return features
diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py
index 6781b9afaf7..ee427eb1ba6 100644
--- a/homeassistant/components/device_sun_light_trigger/__init__.py
+++ b/homeassistant/components/device_sun_light_trigger/__init__.py
@@ -29,14 +29,14 @@ from homeassistant.const import (
SUN_EVENT_SUNSET,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import (
async_track_point_in_utc_time,
async_track_state_change_event,
)
from homeassistant.helpers.sun import get_astral_event_next, is_up
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
DOMAIN = "device_sun_light_trigger"
CONF_DEVICE_GROUP = "device_group"
diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py
index 50fc3d2d936..db33d5038fc 100644
--- a/homeassistant/components/device_tracker/config_entry.py
+++ b/homeassistant/components/device_tracker/config_entry.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from typing import final
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py
index 5dff5837b4b..f2f782d3d97 100644
--- a/homeassistant/components/device_tracker/legacy.py
+++ b/homeassistant/components/device_tracker/legacy.py
@@ -10,7 +10,7 @@ from types import ModuleType
from typing import Any, Final, Protocol, final
import attr
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant import util
@@ -978,9 +978,9 @@ class DeviceScanner:
async def async_scan_devices(self) -> list[str]:
"""Scan for devices."""
- assert (
- self.hass is not None
- ), "hass should be set by async_setup_scanner_platform"
+ assert self.hass is not None, (
+ "hass should be set by async_setup_scanner_platform"
+ )
return await self.hass.async_add_executor_job(self.scan_devices)
def get_device_name(self, device: str) -> str | None:
@@ -989,9 +989,9 @@ class DeviceScanner:
async def async_get_device_name(self, device: str) -> str | None:
"""Get the name of a device."""
- assert (
- self.hass is not None
- ), "hass should be set by async_setup_scanner_platform"
+ assert self.hass is not None, (
+ "hass should be set by async_setup_scanner_platform"
+ )
return await self.hass.async_add_executor_job(self.get_device_name, device)
def get_extra_attributes(self, device: str) -> dict:
@@ -1000,9 +1000,9 @@ class DeviceScanner:
async def async_get_extra_attributes(self, device: str) -> dict:
"""Get the extra attributes of a device."""
- assert (
- self.hass is not None
- ), "hass should be set by async_setup_scanner_platform"
+ assert self.hass is not None, (
+ "hass should be set by async_setup_scanner_platform"
+ )
return await self.hass.async_add_executor_job(self.get_extra_attributes, device)
diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py
index e15204af7c2..c4f57b2398a 100644
--- a/homeassistant/components/devolo_home_control/config_flow.py
+++ b/homeassistant/components/devolo_home_control/config_flow.py
@@ -7,7 +7,6 @@ from typing import Any
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
@@ -16,6 +15,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import configure_mydevolo
from .const import DOMAIN, SUPPORTED_MODEL_TYPES
@@ -48,7 +48,7 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
return self._show_form(step_id="user", errors={"base": "invalid_auth"})
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
# Check if it is a gateway
diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py
index 7c8dccd1a7b..bd2f23d602f 100644
--- a/homeassistant/components/devolo_home_network/config_flow.py
+++ b/homeassistant/components/devolo_home_network/config_flow.py
@@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE
@@ -81,7 +82,7 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if discovery_info.properties["MT"] in ["2600", "2601"]:
diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py
index 240686ed3bb..91e8dd83b7d 100644
--- a/homeassistant/components/devolo_home_network/image.py
+++ b/homeassistant/components/devolo_home_network/image.py
@@ -13,7 +13,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import DevoloHomeNetworkConfigEntry
from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI
diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json
index d10e14f9081..9b1e181d7c0 100644
--- a/homeassistant/components/devolo_home_network/manifest.json
+++ b/homeassistant/components/devolo_home_network/manifest.json
@@ -3,6 +3,7 @@
"name": "devolo Home Network",
"codeowners": ["@2Fake", "@Shutgun"],
"config_flow": true,
+ "dependencies": ["zeroconf"],
"documentation": "https://www.home-assistant.io/integrations/devolo_home_network",
"integration_type": "device",
"iot_class": "local_polling",
diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py
index e93e8e66358..54722c8dade 100644
--- a/homeassistant/components/dexcom/__init__.py
+++ b/homeassistant/components/dexcom/__init__.py
@@ -1,24 +1,16 @@
"""The Dexcom integration."""
-from datetime import timedelta
-import logging
+from pydexcom import AccountError, Dexcom, SessionError
-from pydexcom import AccountError, Dexcom, GlucoseReading, SessionError
-
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS
-
-_LOGGER = logging.getLogger(__name__)
-
-SCAN_INTERVAL = timedelta(seconds=180)
+from .const import CONF_SERVER, PLATFORMS, SERVER_OUS
+from .coordinator import DexcomConfigEntry, DexcomCoordinator
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bool:
"""Set up Dexcom from a config entry."""
try:
dexcom = await hass.async_add_executor_job(
@@ -32,31 +24,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SessionError as error:
raise ConfigEntryNotReady from error
- async def async_update_data():
- try:
- return await hass.async_add_executor_job(dexcom.get_current_glucose_reading)
- except SessionError as error:
- raise UpdateFailed(error) from error
-
- coordinator = DataUpdateCoordinator[GlucoseReading](
- hass,
- _LOGGER,
- config_entry=entry,
- name=DOMAIN,
- update_method=async_update_data,
- update_interval=SCAN_INTERVAL,
- )
+ coordinator = DexcomCoordinator(hass, entry=entry, dexcom=dexcom)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/dexcom/coordinator.py b/homeassistant/components/dexcom/coordinator.py
new file mode 100644
index 00000000000..a9e14def350
--- /dev/null
+++ b/homeassistant/components/dexcom/coordinator.py
@@ -0,0 +1,44 @@
+"""Coordinator for the Dexcom integration."""
+
+from datetime import timedelta
+import logging
+
+from pydexcom import Dexcom, GlucoseReading
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+_SCAN_INTERVAL = timedelta(seconds=180)
+
+type DexcomConfigEntry = ConfigEntry[DexcomCoordinator]
+
+
+class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]):
+ """Dexcom Coordinator."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: DexcomConfigEntry,
+ dexcom: Dexcom,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=DOMAIN,
+ update_interval=_SCAN_INTERVAL,
+ )
+ self.dexcom = dexcom
+
+ async def _async_update_data(self) -> GlucoseReading:
+ """Fetch data from API endpoint."""
+ return await self.hass.async_add_executor_job(
+ self.dexcom.get_current_glucose_reading
+ )
diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py
index 850678e7ac9..cdb1894b675 100644
--- a/homeassistant/components/dexcom/sensor.py
+++ b/homeassistant/components/dexcom/sensor.py
@@ -2,20 +2,15 @@
from __future__ import annotations
-from pydexcom import GlucoseReading
-
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
+from .coordinator import DexcomConfigEntry, DexcomCoordinator
TRENDS = {
1: "rising_quickly",
@@ -30,11 +25,11 @@ TRENDS = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DexcomConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Dexcom sensors."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
username = config_entry.data[CONF_USERNAME]
async_add_entities(
[
@@ -44,16 +39,14 @@ async def async_setup_entry(
)
-class DexcomSensorEntity(
- CoordinatorEntity[DataUpdateCoordinator[GlucoseReading]], SensorEntity
-):
+class DexcomSensorEntity(CoordinatorEntity[DexcomCoordinator], SensorEntity):
"""Base Dexcom sensor entity."""
_attr_has_entity_name = True
def __init__(
self,
- coordinator: DataUpdateCoordinator[GlucoseReading],
+ coordinator: DexcomCoordinator,
username: str,
entry_id: str,
key: str,
@@ -78,7 +71,7 @@ class DexcomGlucoseValueSensor(DexcomSensorEntity):
def __init__(
self,
- coordinator: DataUpdateCoordinator,
+ coordinator: DexcomCoordinator,
username: str,
entry_id: str,
) -> None:
@@ -101,7 +94,7 @@ class DexcomGlucoseTrendSensor(DexcomSensorEntity):
_attr_options = list(TRENDS.values())
def __init__(
- self, coordinator: DataUpdateCoordinator, username: str, entry_id: str
+ self, coordinator: DexcomCoordinator, username: str, entry_id: str
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, username, entry_id, "trend")
diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py
index 2de676ef52a..a11a0b262b0 100644
--- a/homeassistant/components/dhcp/__init__.py
+++ b/homeassistant/components/dhcp/__init__.py
@@ -7,7 +7,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from fnmatch import translate
-from functools import lru_cache
+from functools import lru_cache, partial
import itertools
import logging
import re
@@ -44,12 +44,17 @@ from homeassistant.core import (
State,
callback,
)
-from homeassistant.data_entry_flow import BaseServiceInfo
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery_flow,
)
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstant,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -57,6 +62,7 @@ from homeassistant.helpers.event import (
async_track_state_added_domain,
async_track_time_interval,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServiceInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import DHCPMatcher, async_get_dhcp
@@ -74,13 +80,11 @@ SCAN_INTERVAL = timedelta(minutes=60)
_LOGGER = logging.getLogger(__name__)
-@dataclass(slots=True)
-class DhcpServiceInfo(BaseServiceInfo):
- """Prepared info from dhcp entries."""
-
- ip: str
- hostname: str
- macaddress: str
+_DEPRECATED_DhcpServiceInfo = DeprecatedConstant(
+ _DhcpServiceInfo,
+ "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo",
+ "2026.2",
+)
@dataclass(slots=True)
@@ -296,7 +300,7 @@ class WatcherBase:
self.hass,
domain,
{"source": config_entries.SOURCE_DHCP},
- DhcpServiceInfo(
+ _DhcpServiceInfo(
ip=ip_address,
hostname=lowercase_hostname,
macaddress=mac_address,
@@ -486,3 +490,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool:
since the devices will not change frequently
"""
return bool(_compile_fnmatch(pattern).match(name))
+
+
+# These can be removed if no deprecated constant are in this module anymore
+__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
+__dir__ = partial(
+ dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
+)
+__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json
index ba773782e1c..0eb7e4a64fc 100644
--- a/homeassistant/components/dhcp/manifest.json
+++ b/homeassistant/components/dhcp/manifest.json
@@ -14,7 +14,7 @@
],
"quality_scale": "internal",
"requirements": [
- "aiodhcpwatcher==1.0.2",
+ "aiodhcpwatcher==1.0.3",
"aiodiscover==2.1.0",
"cached-ipaddress==0.8.0"
]
diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py
index b23b7cef2bd..7bc43f2c3f5 100644
--- a/homeassistant/components/diagnostics/__init__.py
+++ b/homeassistant/components/diagnostics/__init__.py
@@ -33,6 +33,7 @@ from homeassistant.loader import (
async_get_integration,
)
from homeassistant.setup import async_get_domain_setup_times
+from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import format_unserializable_data
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
@@ -44,6 +45,7 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+_DIAGNOSTICS_DATA: HassKey[DiagnosticsData] = HassKey(DOMAIN)
@dataclass(slots=True)
@@ -72,7 +74,7 @@ class DiagnosticsData:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Diagnostics from a config entry."""
- hass.data[DOMAIN] = DiagnosticsData()
+ hass.data[_DIAGNOSTICS_DATA] = DiagnosticsData()
await integration_platform.async_process_integration_platforms(
hass, DOMAIN, _register_diagnostics_platform
@@ -104,7 +106,7 @@ def _register_diagnostics_platform(
hass: HomeAssistant, integration_domain: str, platform: DiagnosticsProtocol
) -> None:
"""Register a diagnostics platform."""
- diagnostics_data: DiagnosticsData = hass.data[DOMAIN]
+ diagnostics_data = hass.data[_DIAGNOSTICS_DATA]
diagnostics_data.platforms[integration_domain] = DiagnosticsPlatformData(
getattr(platform, "async_get_config_entry_diagnostics", None),
getattr(platform, "async_get_device_diagnostics", None),
@@ -118,7 +120,7 @@ def handle_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""List all possible diagnostic handlers."""
- diagnostics_data: DiagnosticsData = hass.data[DOMAIN]
+ diagnostics_data = hass.data[_DIAGNOSTICS_DATA]
result = [
{
"domain": domain,
@@ -145,7 +147,7 @@ def handle_get(
) -> None:
"""List all diagnostic handlers for a domain."""
domain = msg["domain"]
- diagnostics_data: DiagnosticsData = hass.data[DOMAIN]
+ diagnostics_data = hass.data[_DIAGNOSTICS_DATA]
if (info := diagnostics_data.platforms.get(domain)) is None:
connection.send_error(
@@ -267,7 +269,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
if (config_entry := hass.config_entries.async_get_entry(d_id)) is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
- diagnostics_data: DiagnosticsData = hass.data[DOMAIN]
+ diagnostics_data = hass.data[_DIAGNOSTICS_DATA]
if (info := diagnostics_data.platforms.get(config_entry.domain)) is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py
index e5b62d430b6..306ddc8e9a5 100644
--- a/homeassistant/components/digital_ocean/__init__.py
+++ b/homeassistant/components/digital_ocean/__init__.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py
index 0d4b31faa2c..f0bb6eba049 100644
--- a/homeassistant/components/digital_ocean/binary_sensor.py
+++ b/homeassistant/components/digital_ocean/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py
index 856c9301cfd..409fa63c1c2 100644
--- a/homeassistant/components/digital_ocean/switch.py
+++ b/homeassistant/components/digital_ocean/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
SwitchEntity,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py
index e59fa4e9d0d..274cc4cbf53 100644
--- a/homeassistant/components/directv/__init__.py
+++ b/homeassistant/components/directv/__init__.py
@@ -12,13 +12,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
-
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
SCAN_INTERVAL = timedelta(seconds=30)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type DirecTVConfigEntry = ConfigEntry[DIRECTV]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> bool:
"""Set up DirecTV from a config entry."""
dtv = DIRECTV(entry.data[CONF_HOST], session=async_get_clientsession(hass))
@@ -27,18 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except DIRECTVError as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = dtv
+ entry.runtime_data = dtv
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py
index 1e0577b4f7c..927d2325c2d 100644
--- a/homeassistant/components/directv/config_flow.py
+++ b/homeassistant/components/directv/config_flow.py
@@ -9,11 +9,11 @@ from urllib.parse import urlparse
from directv import DIRECTV, DIRECTVError
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo
from .const import CONF_RECEIVER_ID, DOMAIN
@@ -67,7 +67,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle SSDP discovery."""
# We can cast the hostname to str because the ssdp_location is not bytes and
@@ -75,10 +75,8 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN):
host = cast(str, urlparse(discovery_info.ssdp_location).hostname)
receiver_id = None
- if discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL):
- receiver_id = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL][
- 4:
- ] # strips off RID-
+ if discovery_info.upnp.get(ATTR_UPNP_SERIAL):
+ receiver_id = discovery_info.upnp[ATTR_UPNP_SERIAL][4:] # strips off RID-
self.context.update({"title_placeholders": {"name": host}})
diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py
index 6c4a40598de..8998e050a75 100644
--- a/homeassistant/components/directv/media_player.py
+++ b/homeassistant/components/directv/media_player.py
@@ -14,17 +14,16 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
+from . import DirecTVConfigEntry
from .const import (
ATTR_MEDIA_CURRENTLY_RECORDING,
ATTR_MEDIA_RATING,
ATTR_MEDIA_RECORDED,
ATTR_MEDIA_START_TIME,
- DOMAIN,
)
from .entity import DIRECTVEntity
@@ -55,11 +54,11 @@ SUPPORT_DTV_CLIENT = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DirecTVConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the DirecTV config entry."""
- dtv = hass.data[DOMAIN][entry.entry_id]
+ dtv = entry.runtime_data
async_add_entities(
(
diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py
index 5a77d90bd3c..dbaab5fa4e6 100644
--- a/homeassistant/components/directv/remote.py
+++ b/homeassistant/components/directv/remote.py
@@ -10,11 +10,10 @@ from typing import Any
from directv import DIRECTV, DIRECTVError
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import DirecTVConfigEntry
from .entity import DIRECTVEntity
_LOGGER = logging.getLogger(__name__)
@@ -24,11 +23,11 @@ SCAN_INTERVAL = timedelta(minutes=2)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DirecTVConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Load DirecTV remote based on a config entry."""
- dtv = hass.data[DOMAIN][entry.entry_id]
+ dtv = entry.runtime_data
async_add_entities(
(
diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py
index 3cea6ec4dac..3c64b9020c3 100644
--- a/homeassistant/components/discogs/sensor.py
+++ b/homeassistant/components/discogs/sensor.py
@@ -16,8 +16,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json
index b82f28a5d11..2f74928c19e 100644
--- a/homeassistant/components/discovergy/manifest.json
+++ b/homeassistant/components/discovergy/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/discovergy",
"integration_type": "service",
"iot_class": "cloud_polling",
+ "quality_scale": "silver",
"requirements": ["pydiscovergy==3.0.2"]
}
diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml
index 3caeaa6bbe0..56af1d97304 100644
--- a/homeassistant/components/discovergy/quality_scale.yaml
+++ b/homeassistant/components/discovergy/quality_scale.yaml
@@ -8,10 +8,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
- config-flow:
- status: todo
- comment: |
- The data_descriptions are missing.
+ config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
@@ -19,7 +16,7 @@ rules:
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
@@ -41,11 +38,11 @@ rules:
status: exempt
comment: |
The integration does not provide any additional options.
- docs-installation-parameters: todo
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates: todo
+ parallel-updates: done
reauthentication-flow: done
test-coverage: done
diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py
index 531904c8740..a3ec132db9b 100644
--- a/homeassistant/components/discovergy/sensor.py
+++ b/homeassistant/components/discovergy/sensor.py
@@ -28,6 +28,8 @@ from . import DiscovergyConfigEntry
from .const import DOMAIN, MANUFACTURER
from .coordinator import DiscovergyUpdateCoordinator
+PARALLEL_UPDATES = 0
+
def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None:
"""Get a value from a Reading and divide with scale it."""
diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json
index b626a11ea1e..0058f874a36 100644
--- a/homeassistant/components/discovergy/strings.json
+++ b/homeassistant/components/discovergy/strings.json
@@ -5,6 +5,10 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "The email address used to log in to your inexogy account.",
+ "password": "The password used to log in to your inexogy account."
}
}
},
@@ -15,7 +19,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "account_mismatch": "The inexogy account authenticated with, does not match the account needed re-authentication.",
+ "account_mismatch": "The inexogy account authenticated with does not match the account that needed re-authentication.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py
index e17f892a7fe..fee9f8dab3c 100644
--- a/homeassistant/components/dlib_face_identify/image_processing.py
+++ b/homeassistant/components/dlib_face_identify/image_processing.py
@@ -15,7 +15,7 @@ from homeassistant.components.image_processing import (
)
from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
from homeassistant.core import HomeAssistant, split_entity_id
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py
index 4452a2958fc..02ef94dae7d 100644
--- a/homeassistant/components/dlink/config_flow.py
+++ b/homeassistant/components/dlink/config_flow.py
@@ -8,9 +8,9 @@ from typing import Any
from pyW215.pyW215 import SmartPlug
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN
@@ -25,7 +25,7 @@ class DLinkFlowHandler(ConfigFlow, domain=DOMAIN):
self.ip_address: str | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
await self.async_set_unique_id(discovery_info.macaddress)
diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py
index 75f50192500..ede9119c50d 100644
--- a/homeassistant/components/dlna_dmr/config_flow.py
+++ b/homeassistant/components/dlna_dmr/config_flow.py
@@ -27,6 +27,14 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE,
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import IntegrationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_SERVICE_LIST,
+ SsdpServiceInfo,
+)
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -60,7 +68,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize flow."""
- self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {}
+ self._discoveries: dict[str, SsdpServiceInfo] = {}
self._location: str | None = None
self._udn: str | None = None
self._device_type: str | None = None
@@ -98,7 +106,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_manual()
self._discoveries = {
- discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
+ discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
or cast(str, urlparse(discovery.ssdp_location).hostname): discovery
for discovery in discoveries
}
@@ -131,7 +139,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by SSDP discovery."""
if LOGGER.isEnabledFor(logging.DEBUG):
@@ -267,7 +275,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=title, data=data, options=self._options)
async def _async_set_info_from_discovery(
- self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True
+ self, discovery_info: SsdpServiceInfo, abort_if_configured: bool = True
) -> None:
"""Set information required for a config entry from the SSDP discovery."""
LOGGER.debug(
@@ -285,7 +293,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st
self._name = (
- discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
+ discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
or urlparse(self._location).hostname
or DEFAULT_NAME
)
@@ -301,12 +309,12 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
updates[CONF_MAC] = self._mac
self._abort_if_unique_id_configured(updates=updates, reload_on_update=False)
- async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]:
+ async def _async_get_discoveries(self) -> list[SsdpServiceInfo]:
"""Get list of unconfigured DLNA devices discovered by SSDP."""
LOGGER.debug("_get_discoveries")
# Get all compatible devices from ssdp's cache
- discoveries: list[ssdp.SsdpServiceInfo] = []
+ discoveries: list[SsdpServiceInfo] = []
for udn_st in DmrDevice.DEVICE_TYPES:
st_discoveries = await ssdp.async_get_discovery_info_by_st(
self.hass, udn_st
@@ -386,7 +394,7 @@ class DlnaDmrOptionsFlowHandler(OptionsFlow):
)
-def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
+def _is_ignored_device(discovery_info: SsdpServiceInfo) -> bool:
"""Return True if this device should be ignored for discovery.
These devices are supported better by other integrations, so don't bug
@@ -402,17 +410,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
return True
# Is the root device not a DMR?
- if (
- discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE)
- not in DmrDevice.DEVICE_TYPES
- ):
+ if discovery_info.upnp.get(ATTR_UPNP_DEVICE_TYPE) not in DmrDevice.DEVICE_TYPES:
return True
# Special cases for devices with other discovery methods (e.g. mDNS), or
# that advertise multiple unrelated (sent in separate discovery packets)
# UPnP devices.
- manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower()
- model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower()
+ manufacturer = (discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) or "").lower()
+ model = (discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "").lower()
if manufacturer.startswith("xbmc") or model == "kodi":
# kodi
@@ -432,14 +437,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
return False
-def _is_dmr_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
+def _is_dmr_device(discovery_info: SsdpServiceInfo) -> bool:
"""Determine if discovery is a complete DLNA DMR device.
Use the discovery_info instead of DmrDevice.is_profile_device to avoid
contacting the device again.
"""
# Abort if the device doesn't support all services required for a DmrDevice.
- discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST)
+ discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST)
if not discovery_service_list:
return False
diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json
index af16379e9c9..82541476a02 100644
--- a/homeassistant/components/dlna_dmr/manifest.json
+++ b/homeassistant/components/dlna_dmr/manifest.json
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
- "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"],
+ "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py
index 443c2101302..563ed209b7d 100644
--- a/homeassistant/components/dlna_dmr/media_player.py
+++ b/homeassistant/components/dlna_dmr/media_player.py
@@ -33,6 +33,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
CONF_BROWSE_UNFILTERED,
@@ -246,7 +247,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
await self._device_disconnect()
async def async_ssdp_callback(
- self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
+ self, info: SsdpServiceInfo, change: ssdp.SsdpChange
) -> None:
"""Handle notification from SSDP of device state change."""
_LOGGER.debug(
diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py
index ad959ece3b6..a87b4a510f5 100644
--- a/homeassistant/components/dlna_dms/config_flow.py
+++ b/homeassistant/components/dlna_dms/config_flow.py
@@ -14,6 +14,11 @@ from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL
from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERVICE_LIST,
+ SsdpServiceInfo,
+)
from .const import CONF_SOURCE_ID, CONFIG_VERSION, DEFAULT_NAME, DOMAIN
from .util import generate_source_id
@@ -33,7 +38,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize flow."""
- self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {}
+ self._discoveries: dict[str, SsdpServiceInfo] = {}
self._location: str | None = None
self._usn: str | None = None
self._name: str | None = None
@@ -60,14 +65,14 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN):
}
discovery_choices = {
- host: f"{discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)} ({host})"
+ host: f"{discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME)} ({host})"
for host, discovery in self._discoveries.items()
}
data_schema = vol.Schema({vol.Optional(CONF_HOST): vol.In(discovery_choices)})
return self.async_show_form(step_id="user", data_schema=data_schema)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by SSDP discovery."""
if LOGGER.isEnabledFor(logging.DEBUG):
@@ -81,7 +86,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN):
# Abort if the device doesn't support all services required for a DmsDevice.
# Use the discovery_info instead of DmsDevice.is_profile_device to avoid
# contacting the device again.
- discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST)
+ discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST)
if not discovery_service_list:
return self.async_abort(reason="not_dms")
@@ -135,7 +140,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=self._name, data=data)
async def _async_parse_discovery(
- self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True
+ self, discovery_info: SsdpServiceInfo, raise_on_progress: bool = True
) -> None:
"""Get required details from an SSDP discovery.
@@ -162,15 +167,15 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._name = (
- discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
+ discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
or urlparse(self._location).hostname
or DEFAULT_NAME
)
- async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]:
+ async def _async_get_discoveries(self) -> list[SsdpServiceInfo]:
"""Get list of unconfigured DLNA devices discovered by SSDP."""
# Get all compatible devices from ssdp's cache
- discoveries: list[ssdp.SsdpServiceInfo] = []
+ discoveries: list[SsdpServiceInfo] = []
for udn_st in DmsDevice.DEVICE_TYPES:
st_discoveries = await ssdp.async_get_discovery_info_by_st(
self.hass, udn_st
diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py
index 8f475d53280..89c53bc2564 100644
--- a/homeassistant/components/dlna_dms/dms.py
+++ b/homeassistant/components/dlna_dms/dms.py
@@ -16,7 +16,7 @@ from async_upnp_client.const import NotificationSubType
from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, UpnpError
from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice
from didl_lite import didl_lite
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components import ssdp
from homeassistant.components.media_player import BrowseError, MediaClass
@@ -29,6 +29,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
CONF_SOURCE_ID,
@@ -220,7 +221,7 @@ class DmsDeviceSource:
await self.device_disconnect()
async def async_ssdp_callback(
- self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
+ self, info: SsdpServiceInfo, change: ssdp.SsdpChange
) -> None:
"""Handle notification from SSDP of device state change."""
LOGGER.debug(
diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json
index ac5bf3719e3..17fc3dc27e8 100644
--- a/homeassistant/components/dlna_dms/manifest.json
+++ b/homeassistant/components/dlna_dms/manifest.json
@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
- "requirements": ["async-upnp-client==0.42.0"],
+ "requirements": ["async-upnp-client==0.43.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py
index 8c2cfa5e556..9e98178e718 100644
--- a/homeassistant/components/dnsip/config_flow.py
+++ b/homeassistant/components/dnsip/config_flow.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONF_HOSTNAME,
diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py
index 9b11b667e84..6fccecfec5c 100644
--- a/homeassistant/components/dominos/__init__.py
+++ b/homeassistant/components/dominos/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components import http
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py
index 51633d0e05d..bcc6e7a8050 100644
--- a/homeassistant/components/doods/image_processing.py
+++ b/homeassistant/components/doods/image_processing.py
@@ -25,8 +25,7 @@ from homeassistant.const import (
CONF_URL,
)
from homeassistant.core import HomeAssistant, split_entity_id
-from homeassistant.helpers import template
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.pil import draw_box
@@ -266,7 +265,7 @@ class Doods(ImageProcessingEntity):
# Draw detected objects
for instance in values:
- box_label = f'{label} {instance["score"]:.1f}%'
+ box_label = f"{label} {instance['score']:.1f}%"
# Already scaled, use 1 for width and height
draw_box(
draw,
diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json
index ae307bb4962..2c672dd4abb 100644
--- a/homeassistant/components/doods/manifest.json
+++ b/homeassistant/components/doods/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
- "requirements": ["pydoods==1.0.2", "Pillow==11.0.0"]
+ "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
}
diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py
index c943fa68766..5090f309c49 100644
--- a/homeassistant/components/doorbird/__init__.py
+++ b/homeassistant/components/doorbird/__init__.py
@@ -17,9 +17,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers import config_validation as cv, 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
from .const import CONF_EVENTS, DOMAIN, PLATFORMS
diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py
index 640d6630c18..45f37527ac1 100644
--- a/homeassistant/components/doorbird/camera.py
+++ b/homeassistant/components/doorbird/camera.py
@@ -10,7 +10,7 @@ import aiohttp
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .entity import DoorBirdEntity
from .models import DoorBirdConfigEntry, DoorBirdData
diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py
index ebb1d6fc126..6a954f5310f 100644
--- a/homeassistant/components/doorbird/config_flow.py
+++ b/homeassistant/components/doorbird/config_flow.py
@@ -11,7 +11,6 @@ from aiohttp import ClientResponseError
from doorbirdpy import DoorBird
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -22,6 +21,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -158,7 +158,7 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="user", data_schema=data, errors=errors)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a discovered doorbird device."""
macaddress = discovery_info.properties["macaddress"]
diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py
index eae5bb6804f..f57e7595dbc 100644
--- a/homeassistant/components/doorbird/device.py
+++ b/homeassistant/components/doorbird/device.py
@@ -15,7 +15,7 @@ from doorbirdpy import (
DoorBirdScheduleEntryOutput,
DoorBirdScheduleEntrySchedule,
)
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py
index b4304e75aab..0a00490f3d9 100644
--- a/homeassistant/components/dormakaba_dkey/__init__.py
+++ b/homeassistant/components/dormakaba_dkey/__init__.py
@@ -2,30 +2,24 @@
from __future__ import annotations
-from datetime import timedelta
-import logging
-
from py_dormakaba_dkey import DKEYLock
-from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated
from py_dormakaba_dkey.models import AssociationData
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.exceptions import ConfigEntryNotReady
-from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS
-from .models import DormakabaDkeyData
+from .const import CONF_ASSOCIATION_DATA
+from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR]
-_LOGGER = logging.getLogger(__name__)
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: DormakabaDkeyConfigEntry
+) -> bool:
"""Set up Dormakaba dKey from a config entry."""
address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
@@ -56,29 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
- async def _async_update() -> None:
- """Update the device state."""
- try:
- await lock.update()
- await lock.disconnect()
- except NotAssociated as ex:
- raise ConfigEntryAuthFailed("Not associated") from ex
- except DKEY_EXCEPTIONS as ex:
- raise UpdateFailed(str(ex)) from ex
-
- coordinator = DataUpdateCoordinator(
- hass,
- _LOGGER,
- config_entry=entry,
- name=lock.name,
- update_method=_async_update,
- update_interval=timedelta(seconds=UPDATE_SECONDS),
- )
+ coordinator = DormakabaDkeyCoordinator(hass, entry, lock)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData(
- lock, coordinator
- )
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -89,13 +64,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
)
+ entry.async_on_unload(coordinator.lock.disconnect)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: DormakabaDkeyConfigEntry
+) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- data: DormakabaDkeyData = hass.data[DOMAIN].pop(entry.entry_id)
- await data.lock.disconnect()
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py
index a8574443e35..56b991bf908 100644
--- a/homeassistant/components/dormakaba_dkey/binary_sensor.py
+++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from py_dormakaba_dkey import DKEYLock
from py_dormakaba_dkey.commands import DoorPosition, Notifications, UnlockStatus
from homeassistant.components.binary_sensor import (
@@ -13,14 +12,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN
+from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
from .entity import DormakabaDkeyEntity
-from .models import DormakabaDkeyData
@dataclass(frozen=True, kw_only=True)
@@ -48,13 +44,13 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DormakabaDkeyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the binary sensor platform for Dormakaba dKey."""
- data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
- DormakabaDkeyBinarySensor(data.coordinator, data.lock, description)
+ DormakabaDkeyBinarySensor(coordinator, description)
for description in BINARY_SENSOR_DESCRIPTIONS
)
@@ -67,16 +63,15 @@ class DormakabaDkeyBinarySensor(DormakabaDkeyEntity, BinarySensorEntity):
def __init__(
self,
- coordinator: DataUpdateCoordinator[None],
- lock: DKEYLock,
+ coordinator: DormakabaDkeyCoordinator,
description: DormakabaDkeyBinarySensorDescription,
) -> None:
"""Initialize a Dormakaba dKey binary sensor."""
self.entity_description = description
- self._attr_unique_id = f"{lock.address}_{description.key}"
- super().__init__(coordinator, lock)
+ self._attr_unique_id = f"{coordinator.lock.address}_{description.key}"
+ super().__init__(coordinator)
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
- self._attr_is_on = self.entity_description.is_on(self._lock.state)
+ self._attr_is_on = self.entity_description.is_on(self.coordinator.lock.state)
diff --git a/homeassistant/components/dormakaba_dkey/coordinator.py b/homeassistant/components/dormakaba_dkey/coordinator.py
new file mode 100644
index 00000000000..32f71ebf59d
--- /dev/null
+++ b/homeassistant/components/dormakaba_dkey/coordinator.py
@@ -0,0 +1,50 @@
+"""Coordinator for the Dormakaba dKey integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from py_dormakaba_dkey import DKEYLock
+from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import UPDATE_SECONDS
+
+_LOGGER = logging.getLogger(__name__)
+
+type DormakabaDkeyConfigEntry = ConfigEntry[DormakabaDkeyCoordinator]
+
+
+class DormakabaDkeyCoordinator(DataUpdateCoordinator[None]):
+ """DormakabaDkey coordinator."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: DormakabaDkeyConfigEntry,
+ lock: DKEYLock,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=lock.name,
+ update_interval=timedelta(seconds=UPDATE_SECONDS),
+ )
+ self.lock = lock
+
+ async def _async_update_data(self) -> None:
+ """Update the device state."""
+ try:
+ await self.lock.update()
+ await self.lock.disconnect()
+ except NotAssociated as ex:
+ raise ConfigEntryAuthFailed("Not associated") from ex
+ except DKEY_EXCEPTIONS as ex:
+ raise UpdateFailed(str(ex)) from ex
diff --git a/homeassistant/components/dormakaba_dkey/entity.py b/homeassistant/components/dormakaba_dkey/entity.py
index 756edccf02f..cc34a70014d 100644
--- a/homeassistant/components/dormakaba_dkey/entity.py
+++ b/homeassistant/components/dormakaba_dkey/entity.py
@@ -4,29 +4,25 @@ from __future__ import annotations
import abc
-from py_dormakaba_dkey import DKEYLock
from py_dormakaba_dkey.commands import Notifications
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import DormakabaDkeyCoordinator
-class DormakabaDkeyEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
+class DormakabaDkeyEntity(CoordinatorEntity[DormakabaDkeyCoordinator]):
"""Dormakaba dKey base entity."""
_attr_has_entity_name = True
- def __init__(
- self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock
- ) -> None:
+ def __init__(self, coordinator: DormakabaDkeyCoordinator) -> None:
"""Initialize a Dormakaba dKey entity."""
super().__init__(coordinator)
- self._lock = lock
+ lock = coordinator.lock
self._attr_device_info = DeviceInfo(
name=lock.device_info.device_name or lock.device_info.device_id,
model="MTL 9291",
@@ -53,5 +49,7 @@ class DormakabaDkeyEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
- self.async_on_remove(self._lock.register_callback(self._handle_state_update))
+ self.async_on_remove(
+ self.coordinator.lock.register_callback(self._handle_state_update)
+ )
return await super().async_added_to_hass()
diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py
index 5f475d37152..352e7cbe0ac 100644
--- a/homeassistant/components/dormakaba_dkey/lock.py
+++ b/homeassistant/components/dormakaba_dkey/lock.py
@@ -4,28 +4,23 @@ from __future__ import annotations
from typing import Any
-from py_dormakaba_dkey import DKEYLock
from py_dormakaba_dkey.commands import UnlockStatus
from homeassistant.components.lock import LockEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN
+from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
from .entity import DormakabaDkeyEntity
-from .models import DormakabaDkeyData
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DormakabaDkeyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the lock platform for Dormakaba dKey."""
- data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id]
- async_add_entities([DormakabaDkeyLock(data.coordinator, data.lock)])
+ async_add_entities([DormakabaDkeyLock(entry.runtime_data)])
class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity):
@@ -33,25 +28,23 @@ class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity):
_attr_has_entity_name = True
- def __init__(
- self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock
- ) -> None:
+ def __init__(self, coordinator: DormakabaDkeyCoordinator) -> None:
"""Initialize a Dormakaba dKey lock."""
- self._attr_unique_id = lock.address
- super().__init__(coordinator, lock)
+ self._attr_unique_id = coordinator.lock.address
+ super().__init__(coordinator)
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
- self._attr_is_locked = self._lock.state.unlock_status in (
+ self._attr_is_locked = self.coordinator.lock.state.unlock_status in (
UnlockStatus.LOCKED,
UnlockStatus.SECURITY_LOCKED,
)
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
- await self._lock.lock()
+ await self.coordinator.lock.lock()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
- await self._lock.unlock()
+ await self.coordinator.lock.unlock()
diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py
deleted file mode 100644
index 23687e82334..00000000000
--- a/homeassistant/components/dormakaba_dkey/models.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""The Dormakaba dKey integration models."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from py_dormakaba_dkey import DKEYLock
-
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-
-
-@dataclass
-class DormakabaDkeyData:
- """Data for the Dormakaba dKey integration."""
-
- lock: DKEYLock
- coordinator: DataUpdateCoordinator[None]
diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py
index e461ba1e44f..b1e941bc7e1 100644
--- a/homeassistant/components/dormakaba_dkey/sensor.py
+++ b/homeassistant/components/dormakaba_dkey/sensor.py
@@ -2,23 +2,18 @@
from __future__ import annotations
-from py_dormakaba_dkey import DKEYLock
-
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN
+from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
from .entity import DormakabaDkeyEntity
-from .models import DormakabaDkeyData
BINARY_SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
@@ -32,13 +27,13 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DormakabaDkeyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the lock platform for Dormakaba dKey."""
- data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
- DormakabaDkeySensor(data.coordinator, data.lock, description)
+ DormakabaDkeySensor(coordinator, description)
for description in BINARY_SENSOR_DESCRIPTIONS
)
@@ -50,16 +45,17 @@ class DormakabaDkeySensor(DormakabaDkeyEntity, SensorEntity):
def __init__(
self,
- coordinator: DataUpdateCoordinator[None],
- lock: DKEYLock,
+ coordinator: DormakabaDkeyCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize a Dormakaba dKey binary sensor."""
self.entity_description = description
- self._attr_unique_id = f"{lock.address}_{description.key}"
- super().__init__(coordinator, lock)
+ self._attr_unique_id = f"{coordinator.lock.address}_{description.key}"
+ super().__init__(coordinator)
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
- self._attr_native_value = getattr(self._lock, self.entity_description.key)
+ self._attr_native_value = getattr(
+ self.coordinator.lock, self.entity_description.key
+ )
diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py
index 5f63bbd0b2b..0a5fb602a08 100644
--- a/homeassistant/components/dovado/__init__.py
+++ b/homeassistant/components/dovado/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
DEVICE_DEFAULT_NAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py
index 013b51bfc8f..e35fdeb2dc0 100644
--- a/homeassistant/components/dovado/sensor.py
+++ b/homeassistant/components/dovado/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_SENSORS, PERCENTAGE, UnitOfInformation
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py
index 75e1103a712..1a45886879a 100644
--- a/homeassistant/components/downloader/__init__.py
+++ b/homeassistant/components/downloader/__init__.py
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py
index 913180db0f7..0cb5c7d9cfc 100644
--- a/homeassistant/components/dremel_3d_printer/config_flow.py
+++ b/homeassistant/components/dremel_3d_printer/config_flow.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, LOGGER
diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py
index bc700456398..52b8f5a7d6e 100644
--- a/homeassistant/components/drop_connect/__init__.py
+++ b/homeassistant/components/drop_connect/__init__.py
@@ -7,12 +7,11 @@ from typing import TYPE_CHECKING
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN
-from .coordinator import DROPDeviceDataUpdateCoordinator
+from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE
+from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -24,7 +23,7 @@ PLATFORMS: list[Platform] = [
]
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, config_entry: DROPConfigEntry) -> bool:
"""Set up DROP from a config entry."""
# Make sure MQTT integration is enabled and the client is available.
@@ -34,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if TYPE_CHECKING:
assert config_entry.unique_id is not None
- drop_data_coordinator = DROPDeviceDataUpdateCoordinator(
- hass, config_entry.unique_id
- )
+ drop_data_coordinator = DROPDeviceDataUpdateCoordinator(hass, config_entry)
@callback
def mqtt_callback(msg: ReceiveMessage) -> None:
@@ -58,15 +55,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
config_entry.data[CONF_DATA_TOPIC],
)
- hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator
+ config_entry.runtime_data = drop_data_coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: DROPConfigEntry
+) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(
- config_entry, PLATFORMS
- ):
- hass.data[DOMAIN].pop(config_entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py
index 093c5bcbb8e..bc8cf900610 100644
--- a/homeassistant/components/drop_connect/binary_sensor.py
+++ b/homeassistant/components/drop_connect/binary_sensor.py
@@ -11,7 +11,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -25,9 +24,8 @@ from .const import (
DEV_RO_FILTER,
DEV_SALT_SENSOR,
DEV_SOFTENER,
- DOMAIN,
)
-from .coordinator import DROPDeviceDataUpdateCoordinator
+from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator
from .entity import DROPEntity
_LOGGER = logging.getLogger(__name__)
@@ -106,7 +104,7 @@ DEVICE_BINARY_SENSORS: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DROPConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the DROP binary sensors from config entry."""
@@ -116,9 +114,10 @@ async def async_setup_entry(
config_entry.entry_id,
)
+ coordinator = config_entry.runtime_data
if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS:
async_add_entities(
- DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor)
+ DROPBinarySensor(coordinator, sensor)
for sensor in BINARY_SENSORS
if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]]
)
diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py
index 0861e091153..d37127d89ed 100644
--- a/homeassistant/components/drop_connect/coordinator.py
+++ b/homeassistant/components/drop_connect/coordinator.py
@@ -16,14 +16,19 @@ from .const import CONF_COMMAND_TOPIC, DOMAIN
_LOGGER = logging.getLogger(__name__)
+type DROPConfigEntry = ConfigEntry[DROPDeviceDataUpdateCoordinator]
+
+
class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""DROP device object."""
- config_entry: ConfigEntry
+ config_entry: DROPConfigEntry
- def __init__(self, hass: HomeAssistant, unique_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, entry: DROPConfigEntry) -> None:
"""Initialize the device."""
- super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}")
+ super().__init__(
+ hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}"
+ )
self.drop_api = DropAPI()
async def set_water(self, value: int) -> None:
diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py
index ad06576c9f3..9e4c74b67e6 100644
--- a/homeassistant/components/drop_connect/select.py
+++ b/homeassistant/components/drop_connect/select.py
@@ -8,12 +8,11 @@ import logging
from typing import Any
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN
-from .coordinator import DROPDeviceDataUpdateCoordinator
+from .const import CONF_DEVICE_TYPE, DEV_HUB
+from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator
from .entity import DROPEntity
_LOGGER = logging.getLogger(__name__)
@@ -50,7 +49,7 @@ DEVICE_SELECTS: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DROPConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the DROP selects from config entry."""
@@ -60,9 +59,10 @@ async def async_setup_entry(
config_entry.entry_id,
)
+ coordinator = config_entry.runtime_data
if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS:
async_add_entities(
- DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select)
+ DROPSelect(coordinator, select)
for select in SELECTS
if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]]
)
diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py
index ad123ee13c7..5ec47ed9eb1 100644
--- a/homeassistant/components/drop_connect/sensor.py
+++ b/homeassistant/components/drop_connect/sensor.py
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
@@ -35,9 +34,8 @@ from .const import (
DEV_PUMP_CONTROLLER,
DEV_RO_FILTER,
DEV_SOFTENER,
- DOMAIN,
)
-from .coordinator import DROPDeviceDataUpdateCoordinator
+from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator
from .entity import DROPEntity
_LOGGER = logging.getLogger(__name__)
@@ -243,7 +241,7 @@ DEVICE_SENSORS: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DROPConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the DROP sensors from config entry."""
@@ -253,9 +251,10 @@ async def async_setup_entry(
config_entry.entry_id,
)
+ coordinator = config_entry.runtime_data
if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS:
async_add_entities(
- DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor)
+ DROPSensor(coordinator, sensor)
for sensor in SENSORS
if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]]
)
diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py
index 98841d7ca24..404059d3196 100644
--- a/homeassistant/components/drop_connect/switch.py
+++ b/homeassistant/components/drop_connect/switch.py
@@ -8,7 +8,6 @@ import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -18,9 +17,8 @@ from .const import (
DEV_HUB,
DEV_PROTECTION_VALVE,
DEV_SOFTENER,
- DOMAIN,
)
-from .coordinator import DROPDeviceDataUpdateCoordinator
+from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator
from .entity import DROPEntity
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +64,7 @@ DEVICE_SWITCHES: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DROPConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the DROP switches from config entry."""
@@ -76,9 +74,10 @@ async def async_setup_entry(
config_entry.entry_id,
)
+ coordinator = config_entry.runtime_data
if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES:
async_add_entities(
- DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch)
+ DROPSwitch(coordinator, switch)
for switch in SWITCHES
if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]]
)
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index 213e948bafb..e05785b8b26 100644
--- a/homeassistant/components/dsmr/sensor.py
+++ b/homeassistant/components/dsmr/sensor.py
@@ -20,6 +20,7 @@ from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram
import serial
from homeassistant.components.sensor import (
+ DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -456,24 +457,29 @@ def rename_old_gas_to_mbus(
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:
+ if ent_reg.async_get_entity_id(
+ SENSOR_DOMAIN, DOMAIN, mbus_device_id
+ ):
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,
- )
+ continue
+ new_device = dev_reg.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, mbus_device_id)},
+ )
+ ent_reg.async_update_entity(
+ entity.entity_id,
+ new_unique_id=mbus_device_id,
+ device_id=new_device.id,
+ )
+ 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
diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py
index 5fc3453fca6..8720be7330f 100644
--- a/homeassistant/components/dublin_bus_transport/sensor.py
+++ b/homeassistant/components/dublin_bus_transport/sensor.py
@@ -20,10 +20,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_RESOURCE = "https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation"
diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py
index 557178de571..a49bfde2785 100644
--- a/homeassistant/components/duckdns/__init__.py
+++ b/homeassistant/components/duckdns/__init__.py
@@ -18,8 +18,8 @@ from homeassistant.core import (
ServiceCall,
callback,
)
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py
index 68b7db12d45..2b0ae46b405 100644
--- a/homeassistant/components/duke_energy/coordinator.py
+++ b/homeassistant/components/duke_energy/coordinator.py
@@ -85,7 +85,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
)
continue
- id_prefix = f"{meter["serviceType"].lower()}_{serial_number}"
+ id_prefix = f"{meter['serviceType'].lower()}_{serial_number}"
consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption"
self._statistic_ids.add(consumption_statistic_id)
_LOGGER.debug(
@@ -136,7 +136,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
)
name_prefix = (
- f"Duke Energy " f"{meter["serviceType"].capitalize()} {serial_number}"
+ f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}"
)
consumption_metadata = StatisticMetaData(
has_mean=False,
diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py
index 27e9e749472..302a7280128 100644
--- a/homeassistant/components/dunehd/__init__.py
+++ b/homeassistant/components/dunehd/__init__.py
@@ -10,29 +10,21 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-
PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type DuneHDConfigEntry = ConfigEntry[DuneHDPlayer]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: DuneHDConfigEntry) -> bool:
"""Set up a config entry."""
- host: str = entry.data[CONF_HOST]
-
- player = DuneHDPlayer(host)
-
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = player
+ entry.runtime_data = DuneHDPlayer(entry.data[CONF_HOST])
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DuneHDConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py
index ded23ea4669..db903cac2bf 100644
--- a/homeassistant/components/dunehd/media_player.py
+++ b/homeassistant/components/dunehd/media_player.py
@@ -15,11 +15,11 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import DuneHDConfigEntry
from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN
CONF_SOURCES: Final = "sources"
@@ -37,14 +37,14 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: DuneHDConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Add Dune HD entities from a config_entry."""
- unique_id = entry.entry_id
-
- player: DuneHDPlayer = hass.data[DOMAIN][entry.entry_id]
-
- async_add_entities([DuneHDPlayerEntity(player, DEFAULT_NAME, unique_id)], True)
+ async_add_entities(
+ [DuneHDPlayerEntity(entry.runtime_data, DEFAULT_NAME, entry.entry_id)], True
+ )
class DuneHDPlayerEntity(MediaPlayerEntity):
diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py
index 1873db45226..766fad49e81 100644
--- a/homeassistant/components/duotecno/__init__.py
+++ b/homeassistant/components/duotecno/__init__.py
@@ -10,8 +10,6 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN
-
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
@@ -21,7 +19,10 @@ PLATFORMS: list[Platform] = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type DuotecnoConfigEntry = ConfigEntry[PyDuotecno]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: DuotecnoConfigEntry) -> bool:
"""Set up duotecno from a config entry."""
controller = PyDuotecno()
@@ -31,14 +32,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
except (OSError, InvalidPassword, LoadFailure) as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller
+
+ entry.runtime_data = controller
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DuotecnoConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py
index 10c807a8023..aadef47b998 100644
--- a/homeassistant/components/duotecno/binary_sensor.py
+++ b/homeassistant/components/duotecno/binary_sensor.py
@@ -2,28 +2,25 @@
from __future__ import annotations
-from duotecno.controller import PyDuotecno
from duotecno.unit import ControlUnit, VirtualUnit
from homeassistant.components.binary_sensor import BinarySensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DuotecnoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Duotecno binary sensor on config_entry."""
- cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
DuotecnoBinarySensor(channel)
- for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"])
+ for channel in entry.runtime_data.get_units(["ControlUnit", "VirtualUnit"])
)
diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py
index 0355d2855d3..83a211d97f5 100644
--- a/homeassistant/components/duotecno/climate.py
+++ b/homeassistant/components/duotecno/climate.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Any, Final
-from duotecno.controller import PyDuotecno
from duotecno.unit import SensUnit
from homeassistant.components.climate import (
@@ -12,12 +11,11 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
HVACMODE: Final = {
@@ -33,13 +31,13 @@ PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()}
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DuotecnoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Duotecno climate based on config_entry."""
- cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"])
+ DuotecnoClimate(channel)
+ for channel in entry.runtime_data.get_units(["SensUnit"])
)
diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py
index 1c4f7d70fc5..7d879741555 100644
--- a/homeassistant/components/duotecno/cover.py
+++ b/homeassistant/components/duotecno/cover.py
@@ -4,27 +4,25 @@ from __future__ import annotations
from typing import Any
-from duotecno.controller import PyDuotecno
from duotecno.unit import DuoswitchUnit
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DuotecnoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the duoswitch endities."""
- cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- DuotecnoCover(channel) for channel in cntrl.get_units("DuoswitchUnit")
+ DuotecnoCover(channel)
+ for channel in entry.runtime_data.get_units("DuoswitchUnit")
)
diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py
index 57635ac2bc2..7b41cbaef22 100644
--- a/homeassistant/components/duotecno/light.py
+++ b/homeassistant/components/duotecno/light.py
@@ -2,26 +2,25 @@
from typing import Any
-from duotecno.controller import PyDuotecno
from duotecno.unit import DimUnit
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DuotecnoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Duotecno light based on config_entry."""
- cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id]
- async_add_entities(DuotecnoLight(channel) for channel in cntrl.get_units("DimUnit"))
+ async_add_entities(
+ DuotecnoLight(channel) for channel in entry.runtime_data.get_units("DimUnit")
+ )
class DuotecnoLight(DuotecnoEntity, LightEntity):
diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py
index b3a87786d4e..0c01a6ca4de 100644
--- a/homeassistant/components/duotecno/switch.py
+++ b/homeassistant/components/duotecno/switch.py
@@ -2,27 +2,25 @@
from typing import Any
-from duotecno.controller import PyDuotecno
from duotecno.unit import SwitchUnit
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DuotecnoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit")
+ DuotecnoSwitch(channel)
+ for channel in entry.runtime_data.get_units("SwitchUnit")
)
diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py
index f148f4e05ac..064cf52d04d 100644
--- a/homeassistant/components/dwd_weather_warnings/config_flow.py
+++ b/homeassistant/components/dwd_weather_warnings/config_flow.py
@@ -8,8 +8,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import CONF_REGION_DEVICE_TRACKER, CONF_REGION_IDENTIFIER, DOMAIN
diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py
index 8cf3813a85d..be61304bc06 100644
--- a/homeassistant/components/dwd_weather_warnings/coordinator.py
+++ b/homeassistant/components/dwd_weather_warnings/coordinator.py
@@ -7,7 +7,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import location
+from homeassistant.util import location as location_util
from .const import (
CONF_REGION_DEVICE_TRACKER,
@@ -58,7 +58,7 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]):
distance = None
if self._previous_position is not None:
- distance = location.distance(
+ distance = location_util.distance(
self._previous_position[0],
self._previous_position[1],
position[0],
diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py
index c1232bab2cf..b43ce3db8c1 100644
--- a/homeassistant/components/dweet/__init__.py
+++ b/homeassistant/components/dweet/__init__.py
@@ -14,8 +14,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py
index 10109189eb0..6110f17f826 100644
--- a/homeassistant/components/dweet/sensor.py
+++ b/homeassistant/components/dweet/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py
index 7388c43cb89..3411882b725 100644
--- a/homeassistant/components/dynalite/__init__.py
+++ b/homeassistant/components/dynalite/__init__.py
@@ -2,113 +2,56 @@
from __future__ import annotations
-import voluptuous as vol
-
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .bridge import DynaliteBridge
-from .const import (
- ATTR_AREA,
- ATTR_CHANNEL,
- ATTR_HOST,
- DOMAIN,
- LOGGER,
- PLATFORMS,
- SERVICE_REQUEST_AREA_PRESET,
- SERVICE_REQUEST_CHANNEL_LEVEL,
-)
+from .const import DOMAIN, LOGGER, PLATFORMS
from .convert_config import convert_config
from .panel import async_register_dynalite_frontend
+from .services import setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+type DynaliteConfigEntry = ConfigEntry[DynaliteBridge]
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dynalite platform."""
- hass.data[DOMAIN] = {}
-
- async def dynalite_service(service_call: ServiceCall) -> None:
- data = service_call.data
- host = data.get(ATTR_HOST, "")
- bridges = [
- bridge
- for bridge in hass.data[DOMAIN].values()
- if not host or bridge.host == host
- ]
- LOGGER.debug("Selected bridged for service call: %s", bridges)
- if service_call.service == SERVICE_REQUEST_AREA_PRESET:
- bridge_attr = "request_area_preset"
- elif service_call.service == SERVICE_REQUEST_CHANNEL_LEVEL:
- bridge_attr = "request_channel_level"
- for bridge in bridges:
- getattr(bridge.dynalite_devices, bridge_attr)(
- data[ATTR_AREA], data.get(ATTR_CHANNEL)
- )
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_REQUEST_AREA_PRESET,
- dynalite_service,
- vol.Schema(
- {
- vol.Optional(ATTR_HOST): cv.string,
- vol.Required(ATTR_AREA): int,
- vol.Optional(ATTR_CHANNEL): int,
- }
- ),
- )
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_REQUEST_CHANNEL_LEVEL,
- dynalite_service,
- vol.Schema(
- {
- vol.Optional(ATTR_HOST): cv.string,
- vol.Required(ATTR_AREA): int,
- vol.Required(ATTR_CHANNEL): int,
- }
- ),
- )
+ setup_services(hass)
await async_register_dynalite_frontend(hass)
return True
-async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_entry_changed(hass: HomeAssistant, entry: DynaliteConfigEntry) -> None:
"""Reload entry since the data has changed."""
LOGGER.debug("Reconfiguring entry %s", entry.data)
- bridge = hass.data[DOMAIN][entry.entry_id]
+ bridge = entry.runtime_data
bridge.reload_config(entry.data)
LOGGER.debug("Reconfiguring entry finished %s", entry.data)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: DynaliteConfigEntry) -> bool:
"""Set up a bridge from a config entry."""
LOGGER.debug("Setting up entry %s", entry.data)
bridge = DynaliteBridge(hass, convert_config(entry.data))
- # need to do it before the listener
- hass.data[DOMAIN][entry.entry_id] = bridge
- entry.async_on_unload(entry.add_update_listener(async_entry_changed))
if not await bridge.async_setup():
LOGGER.error("Could not set up bridge for entry %s", entry.data)
- hass.data[DOMAIN][entry.entry_id] = None
raise ConfigEntryNotReady
+ entry.runtime_data = bridge
+ entry.async_on_unload(entry.add_update_listener(async_entry_changed))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DynaliteConfigEntry) -> bool:
"""Unload a config entry."""
LOGGER.debug("Unloading entry %s", entry.data)
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py
index 6f090371eee..0e491281619 100644
--- a/homeassistant/components/dynalite/bridge.py
+++ b/homeassistant/components/dynalite/bridge.py
@@ -16,6 +16,7 @@ from dynalite_devices_lib.dynalite_devices import (
DynaliteNotification,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -23,6 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ATTR_AREA, ATTR_HOST, ATTR_PACKET, ATTR_PRESET, LOGGER, PLATFORMS
from .convert_config import convert_config
+type DynaliteConfigEntry = ConfigEntry[DynaliteBridge]
+
class DynaliteBridge:
"""Manages a single Dynalite bridge."""
diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py
index d7f366d919c..17adf1947ec 100644
--- a/homeassistant/components/dynalite/cover.py
+++ b/homeassistant/components/dynalite/cover.py
@@ -7,18 +7,17 @@ from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.enum import try_parse_enum
-from .bridge import DynaliteBridge
+from .bridge import DynaliteBridge, DynaliteConfigEntry
from .entity import DynaliteBase, async_setup_entry_base
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DynaliteConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
diff --git a/homeassistant/components/dynalite/entity.py b/homeassistant/components/dynalite/entity.py
index 62667dc19c3..7957e9c8515 100644
--- a/homeassistant/components/dynalite/entity.py
+++ b/homeassistant/components/dynalite/entity.py
@@ -6,27 +6,26 @@ from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
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.restore_state import RestoreEntity
-from .bridge import DynaliteBridge
+from .bridge import DynaliteBridge, DynaliteConfigEntry
from .const import DOMAIN, LOGGER
def async_setup_entry_base(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DynaliteConfigEntry,
async_add_entities: AddEntitiesCallback,
platform: str,
entity_from_device: Callable,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data)
- bridge = hass.data[DOMAIN][config_entry.entry_id]
+ bridge = config_entry.runtime_data
@callback
def async_add_entities_platform(devices):
diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py
index e0dd8b147aa..ea2bc2bc96f 100644
--- a/homeassistant/components/dynalite/light.py
+++ b/homeassistant/components/dynalite/light.py
@@ -3,16 +3,16 @@
from typing import Any
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from .bridge import DynaliteConfigEntry
from .entity import DynaliteBase, async_setup_entry_base
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DynaliteConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py
new file mode 100644
index 00000000000..d0d57a582b4
--- /dev/null
+++ b/homeassistant/components/dynalite/services.py
@@ -0,0 +1,79 @@
+"""Support for the Dynalite networks."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import config_validation as cv
+
+from .bridge import DynaliteBridge
+from .const import (
+ ATTR_AREA,
+ ATTR_CHANNEL,
+ ATTR_HOST,
+ DOMAIN,
+ LOGGER,
+ SERVICE_REQUEST_AREA_PRESET,
+ SERVICE_REQUEST_CHANNEL_LEVEL,
+)
+
+
+@callback
+def _get_bridges(service_call: ServiceCall) -> list[DynaliteBridge]:
+ host = service_call.data.get(ATTR_HOST, "")
+ bridges = [
+ entry.runtime_data
+ for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN)
+ if not host or entry.runtime_data.host == host
+ ]
+ LOGGER.debug("Selected bridges for service call: %s", bridges)
+ return bridges
+
+
+async def _request_area_preset(service_call: ServiceCall) -> None:
+ bridges = _get_bridges(service_call)
+ data = service_call.data
+ for bridge in bridges:
+ bridge.dynalite_devices.request_area_preset(
+ data[ATTR_AREA], data.get(ATTR_CHANNEL)
+ )
+
+
+async def _request_channel_level(service_call: ServiceCall) -> None:
+ bridges = _get_bridges(service_call)
+ data = service_call.data
+ for bridge in bridges:
+ bridge.dynalite_devices.request_channel_level(
+ data[ATTR_AREA], data[ATTR_CHANNEL]
+ )
+
+
+@callback
+def setup_services(hass: HomeAssistant) -> None:
+ """Set up the Dynalite platform."""
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_REQUEST_AREA_PRESET,
+ _request_area_preset,
+ vol.Schema(
+ {
+ vol.Optional(ATTR_HOST): cv.string,
+ vol.Required(ATTR_AREA): int,
+ vol.Optional(ATTR_CHANNEL): int,
+ }
+ ),
+ )
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_REQUEST_CHANNEL_LEVEL,
+ _request_channel_level,
+ vol.Schema(
+ {
+ vol.Optional(ATTR_HOST): cv.string,
+ vol.Required(ATTR_AREA): int,
+ vol.Required(ATTR_CHANNEL): int,
+ }
+ ),
+ )
diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py
index d24a098056a..dd6aad8670c 100644
--- a/homeassistant/components/dynalite/switch.py
+++ b/homeassistant/components/dynalite/switch.py
@@ -3,17 +3,17 @@
from typing import Any
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from .bridge import DynaliteConfigEntry
from .entity import DynaliteBase, async_setup_entry_base
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: DynaliteConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
diff --git a/homeassistant/components/eafm/__init__.py b/homeassistant/components/eafm/__init__.py
index dc618a983f3..e2af2bae9f5 100644
--- a/homeassistant/components/eafm/__init__.py
+++ b/homeassistant/components/eafm/__init__.py
@@ -1,64 +1,22 @@
"""UK Environment Agency Flood Monitoring Integration."""
-import asyncio
-from datetime import timedelta
-import logging
-from typing import Any
-
-from aioeafm import get_station
-
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN
+from .coordinator import EafmConfigEntry, EafmCoordinator
PLATFORMS = [Platform.SENSOR]
-_LOGGER = logging.getLogger(__name__)
-
-def get_measures(station_data):
- """Force measure key to always be a list."""
- if "measures" not in station_data:
- return []
- if isinstance(station_data["measures"], dict):
- return [station_data["measures"]]
- return station_data["measures"]
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool:
"""Set up flood monitoring sensors for this config entry."""
- station_key = entry.data["station"]
- session = async_get_clientsession(hass=hass)
-
- async def _async_update_data() -> dict[str, dict[str, Any]]:
- # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts
- async with asyncio.timeout(30):
- data = await get_station(session, station_key)
-
- measures = get_measures(data)
- # Turn data.measures into a dict rather than a list so easier for entities to
- # find themselves.
- data["measures"] = {measure["@id"]: measure for measure in measures}
- return data
-
- coordinator = DataUpdateCoordinator[dict[str, dict[str, Any]]](
- hass,
- _LOGGER,
- config_entry=entry,
- name="sensor",
- update_method=_async_update_data,
- update_interval=timedelta(seconds=15 * 60),
- )
+ coordinator = EafmCoordinator(hass, entry=entry)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool:
"""Unload flood monitoring sensors."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/eafm/coordinator.py b/homeassistant/components/eafm/coordinator.py
new file mode 100644
index 00000000000..375368210a5
--- /dev/null
+++ b/homeassistant/components/eafm/coordinator.py
@@ -0,0 +1,57 @@
+"""UK Environment Agency Flood Monitoring Integration."""
+
+import asyncio
+from datetime import timedelta
+import logging
+from typing import Any
+
+from aioeafm import get_station
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+PLATFORMS = [Platform.SENSOR]
+
+_LOGGER = logging.getLogger(__name__)
+
+type EafmConfigEntry = ConfigEntry[EafmCoordinator]
+
+
+def _get_measures(station_data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Force measure key to always be a list."""
+ if "measures" not in station_data:
+ return []
+ if isinstance(station_data["measures"], dict):
+ return [station_data["measures"]]
+ return station_data["measures"]
+
+
+class EafmCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
+ """Class to manage fetching UK Flood Monitoring data."""
+
+ def __init__(self, hass: HomeAssistant, entry: EafmConfigEntry) -> None:
+ """Initialize."""
+ self._station_key = entry.data["station"]
+ self._session = async_get_clientsession(hass=hass)
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name="sensor",
+ update_interval=timedelta(seconds=15 * 60),
+ )
+
+ async def _async_update_data(self) -> dict[str, dict[str, Any]]:
+ """Fetch the latest data from the source."""
+ # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts
+ async with asyncio.timeout(30):
+ data = await get_station(self._session, self._station_key)
+
+ measures = _get_measures(data)
+ # Turn data.measures into a dict rather than a list so easier for entities to
+ # find themselves.
+ data["measures"] = {measure["@id"]: measure for measure in measures}
+ return data
diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py
index 297f4d6d2c8..d9b18cbc663 100644
--- a/homeassistant/components/eafm/sensor.py
+++ b/homeassistant/components/eafm/sensor.py
@@ -3,17 +3,14 @@
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorStateClass
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
+from .coordinator import EafmConfigEntry, EafmCoordinator
UNIT_MAPPING = {
"http://qudt.org/1.1/vocab/unit#Meter": UnitOfLength.METERS,
@@ -22,11 +19,11 @@ UNIT_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EafmConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up UK Flood Monitoring Sensors."""
- coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
created_entities: set[str] = set()
@callback
@@ -70,7 +67,7 @@ class Measurement(CoordinatorEntity, SensorEntity):
_attr_has_entity_name = True
_attr_name = None
- def __init__(self, coordinator, key):
+ def __init__(self, coordinator: EafmCoordinator, key: str) -> None:
"""Initialise the gauge with a data instance and station."""
super().__init__(coordinator)
self.key = key
diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json
index c42ef9df5ac..96afffdf78f 100644
--- a/homeassistant/components/easyenergy/strings.json
+++ b/homeassistant/components/easyenergy/strings.json
@@ -57,11 +57,11 @@
"services": {
"get_gas_prices": {
"name": "Get gas prices",
- "description": "Request gas prices from easyEnergy.",
+ "description": "Requests gas prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "Config Entry",
- "description": "The config entry to use for this service."
+ "description": "The configuration entry to use for this action."
},
"incl_vat": {
"name": "VAT Included",
@@ -79,7 +79,7 @@
},
"get_energy_usage_prices": {
"name": "Get energy usage prices",
- "description": "Request usage energy prices from easyEnergy.",
+ "description": "Requests usage energy prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
@@ -101,7 +101,7 @@
},
"get_energy_return_prices": {
"name": "Get energy return prices",
- "description": "Request return energy prices from easyEnergy.",
+ "description": "Requests return energy prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py
index 691e9dd8275..a7628e78a9a 100644
--- a/homeassistant/components/ebox/sensor.py
+++ b/homeassistant/components/ebox/sensor.py
@@ -29,8 +29,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py
index c9386999fae..4cb8d92c391 100644
--- a/homeassistant/components/ebusd/__init__.py
+++ b/homeassistant/components/ebusd/__init__.py
@@ -13,7 +13,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py
index 2eaaddf7e2f..ccd04be585e 100644
--- a/homeassistant/components/ebusd/sensor.py
+++ b/homeassistant/components/ebusd/sensor.py
@@ -9,8 +9,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py
index e9b519c7095..0648dfb56bf 100644
--- a/homeassistant/components/ecoal_boiler/__init__.py
+++ b/homeassistant/components/ecoal_boiler/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py
index 54af6c0f801..c34211e9ff0 100644
--- a/homeassistant/components/ecobee/__init__.py
+++ b/homeassistant/components/ecobee/__init__.py
@@ -3,72 +3,36 @@
from datetime import timedelta
from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
-from .const import (
- _LOGGER,
- CONF_REFRESH_TOKEN,
- DATA_ECOBEE_CONFIG,
- DATA_HASS_CONFIG,
- DOMAIN,
- PLATFORMS,
-)
+from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA
-)
+type EcobeeConfigEntry = ConfigEntry[EcobeeData]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Ecobee uses config flow for configuration.
-
- But, an "ecobee:" entry in configuration.yaml will trigger an import flow
- if a config entry doesn't already exist. If ecobee.conf exists, the import
- flow will attempt to import it and create a config entry, to assist users
- migrating from the old ecobee integration. Otherwise, the user will have to
- continue setting up the integration via the config flow.
- """
-
- hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {})
- hass.data[DATA_HASS_CONFIG] = config
-
- if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]:
- # No config entry exists and configuration.yaml config exists, trigger the import flow.
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}
- )
- )
-
- return True
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
"""Set up ecobee via a config entry."""
api_key = entry.data[CONF_API_KEY]
refresh_token = entry.data[CONF_REFRESH_TOKEN]
- data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
+ runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
- if not await data.refresh():
+ if not await runtime_data.refresh():
return False
- await data.update()
+ await runtime_data.update()
- if data.ecobee.thermostats is None:
+ if runtime_data.ecobee.thermostats is None:
_LOGGER.error("No ecobee devices found to set up")
return False
- hass.data[DOMAIN] = data
+ entry.runtime_data = runtime_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -117,9 +81,6 @@ class EcobeeData:
return False
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
"""Unload the config entry and platforms."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data.pop(DOMAIN)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py
index 2a021442a63..9c9f2192f43 100644
--- a/homeassistant/components/ecobee/binary_sensor.py
+++ b/homeassistant/components/ecobee/binary_sensor.py
@@ -6,21 +6,21 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import EcobeeConfigEntry
from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ecobee binary (occupancy) sensors."""
- data = hass.data[DOMAIN]
+ data = config_entry.runtime_data
dev = []
for index in range(len(data.ecobee.thermostats)):
for sensor in data.ecobee.get_remote_sensors(index):
diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
index 709926d8496..743e2e1ba4b 100644
--- a/homeassistant/components/ecobee/climate.py
+++ b/homeassistant/components/ecobee/climate.py
@@ -21,7 +21,6 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
@@ -33,13 +32,16 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import device_registry as dr, entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_platform,
+)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
-from . import EcobeeData
+from . import EcobeeConfigEntry, EcobeeData
from .const import (
_LOGGER,
ATTR_ACTIVE_SENSORS,
@@ -201,12 +203,12 @@ SUPPORT_FLAGS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat."""
- data = hass.data[DOMAIN]
+ data = config_entry.runtime_data
entities = []
for index in range(len(data.ecobee.thermostats)):
@@ -603,7 +605,7 @@ class Thermostat(ClimateEntity):
"""Return the remote sensor device name_by_user or name for the thermostat."""
return sorted(
[
- f'{item["name_by_user"]} ({item["id"]})'
+ f"{item['name_by_user']} ({item['id']})"
for item in self.remote_sensor_ids_names
]
)
@@ -873,7 +875,7 @@ class Thermostat(ClimateEntity):
translation_placeholders={
"options": ", ".join(
[
- f'{item["name_by_user"]} ({item["id"]})'
+ f"{item['name_by_user']} ({item['id']})"
for item in self.remote_sensor_ids_names
]
)
diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py
index 687d9173a66..ac834e92ca8 100644
--- a/homeassistant/components/ecobee/config_flow.py
+++ b/homeassistant/components/ecobee/config_flow.py
@@ -2,20 +2,15 @@
from typing import Any
-from pyecobee import (
- ECOBEE_API_KEY,
- ECOBEE_CONFIG_FILENAME,
- ECOBEE_REFRESH_TOKEN,
- Ecobee,
-)
+from pyecobee import ECOBEE_API_KEY, Ecobee
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util.json import load_json_object
-from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN
+from .const import CONF_REFRESH_TOKEN, DOMAIN
+
+_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -30,11 +25,6 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors = {}
- stored_api_key = (
- self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY)
- if DATA_ECOBEE_CONFIG in self.hass.data
- else ""
- )
if user_input is not None:
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
@@ -47,9 +37,7 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {vol.Required(CONF_API_KEY, default=stored_api_key): str}
- ),
+ data_schema=_USER_SCHEMA,
errors=errors,
)
@@ -75,50 +63,3 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
description_placeholders={"pin": self._ecobee.pin},
)
-
- async def async_step_import(self, import_data: None) -> ConfigFlowResult:
- """Import ecobee config from configuration.yaml.
-
- Triggered by async_setup only if a config entry doesn't already exist.
- If ecobee.conf exists, we will attempt to validate the credentials
- and create an entry if valid. Otherwise, we will delegate to the user
- step so that the user can continue the config flow.
- """
- try:
- legacy_config = await self.hass.async_add_executor_job(
- load_json_object, self.hass.config.path(ECOBEE_CONFIG_FILENAME)
- )
- config = {
- ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY],
- ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN],
- }
- except (HomeAssistantError, KeyError):
- _LOGGER.debug(
- "No valid ecobee.conf configuration found for import, delegating to"
- " user step"
- )
- return await self.async_step_user(
- user_input={
- CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY)
- }
- )
-
- ecobee = Ecobee(config=config)
- if await self.hass.async_add_executor_job(ecobee.refresh_tokens):
- # Credentials found and validated; create the entry.
- _LOGGER.debug(
- "Valid ecobee configuration found for import, creating configuration"
- " entry"
- )
- return self.async_create_entry(
- title=DOMAIN,
- data={
- CONF_API_KEY: ecobee.api_key,
- CONF_REFRESH_TOKEN: ecobee.refresh_token,
- },
- )
- return await self.async_step_user(
- user_input={
- CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY)
- }
- )
diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py
index d0e9ba8e8e9..115c91eceeb 100644
--- a/homeassistant/components/ecobee/const.py
+++ b/homeassistant/components/ecobee/const.py
@@ -20,8 +20,6 @@ from homeassistant.const import Platform
_LOGGER = logging.getLogger(__package__)
DOMAIN = "ecobee"
-DATA_ECOBEE_CONFIG = "ecobee_config"
-DATA_HASS_CONFIG = "ecobee_hass_config"
ATTR_CONFIG_ENTRY_ID = "entry_id"
ATTR_AVAILABLE_SENSORS = "available_sensors"
ATTR_ACTIVE_SENSORS = "active_sensors"
diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py
index d9616383ab6..982cbdd07f2 100644
--- a/homeassistant/components/ecobee/humidifier.py
+++ b/homeassistant/components/ecobee/humidifier.py
@@ -12,11 +12,11 @@ from homeassistant.components.humidifier import (
HumidifierEntity,
HumidifierEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import EcobeeConfigEntry
from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
SCAN_INTERVAL = timedelta(minutes=3)
@@ -27,11 +27,11 @@ MODE_OFF = "off"
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat humidifier entity."""
- data = hass.data[DOMAIN]
+ data = config_entry.runtime_data
entities = []
for index in range(len(data.ecobee.thermostats)):
thermostat = data.ecobee.get_thermostat(index)
diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py
index 28cfbebe506..7c70d7ae4ac 100644
--- a/homeassistant/components/ecobee/notify.py
+++ b/homeassistant/components/ecobee/notify.py
@@ -3,22 +3,20 @@
from __future__ import annotations
from homeassistant.components.notify import NotifyEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import EcobeeData
-from .const import DOMAIN
+from . import EcobeeConfigEntry, EcobeeData
from .entity import EcobeeBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat."""
- data: EcobeeData = hass.data[DOMAIN]
+ data = config_entry.runtime_data
async_add_entities(
EcobeeNotifyEntity(data, index) for index in range(len(data.ecobee.thermostats))
)
@@ -34,7 +32,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity):
"""Initialize the thermostat."""
super().__init__(data, thermostat_index)
self._attr_unique_id = (
- f"{self.thermostat["identifier"]}_notify_{thermostat_index}"
+ f"{self.thermostat['identifier']}_notify_{thermostat_index}"
)
def send_message(self, message: str, title: str | None = None) -> None:
diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py
index ed3744bf11e..f047ea8f896 100644
--- a/homeassistant/components/ecobee/number.py
+++ b/homeassistant/components/ecobee/number.py
@@ -12,13 +12,11 @@ from homeassistant.components.number import (
NumberEntityDescription,
NumberMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import EcobeeData
-from .const import DOMAIN
+from . import EcobeeConfigEntry, EcobeeData
from .entity import EcobeeBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -54,11 +52,11 @@ VENTILATOR_NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat number entity."""
- data: EcobeeData = hass.data[DOMAIN]
+ data = config_entry.runtime_data
assert data is not None
diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py
index fe0442fb885..1b50fc21edf 100644
--- a/homeassistant/components/ecobee/sensor.py
+++ b/homeassistant/components/ecobee/sensor.py
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
@@ -23,6 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import EcobeeConfigEntry
from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
@@ -73,11 +73,11 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ecobee sensors."""
- data = hass.data[DOMAIN]
+ data = config_entry.runtime_data
entities = [
EcobeeSensor(data, sensor["name"], index, description)
for index in range(len(data.ecobee.thermostats))
diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json
index 8c636bd9b04..2b44c45edef 100644
--- a/homeassistant/components/ecobee/strings.json
+++ b/homeassistant/components/ecobee/strings.json
@@ -71,7 +71,7 @@
},
"start_date": {
"name": "Start date",
- "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time)."
+ "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with 'Start time')."
},
"start_time": {
"name": "Start time",
@@ -79,7 +79,7 @@
},
"end_date": {
"name": "End date",
- "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with end_time)."
+ "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with 'End time')."
},
"end_time": {
"name": "End time",
@@ -138,7 +138,7 @@
}
},
"set_dst_mode": {
- "name": "Set Daylight savings time mode",
+ "name": "Set daylight savings time mode",
"description": "Enables/disables automatic daylight savings time.",
"fields": {
"dst_enabled": {
@@ -149,11 +149,11 @@
},
"set_mic_mode": {
"name": "Set mic mode",
- "description": "Enables/disables Alexa mic (only for Ecobee 4).",
+ "description": "Enables/disables Alexa microphone (only for Ecobee 4).",
"fields": {
"mic_enabled": {
"name": "Mic enabled",
- "description": "Enable Alexa mic."
+ "description": "Enable Alexa microphone."
}
}
},
@@ -172,8 +172,8 @@
}
},
"set_sensors_used_in_climate": {
- "name": "Set Sensors Used in Climate",
- "description": "Sets the participating sensors for a climate.",
+ "name": "Set sensors used in climate",
+ "description": "Sets the participating sensors for a climate program.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -198,7 +198,7 @@
"message": "Invalid sensor for thermostat, available options are: {options}"
},
"sensor_lookup_failed": {
- "message": "There was an error getting the sensor ids from sensor names. Try reloading the ecobee integration."
+ "message": "There was an error getting the sensor IDs from sensor names. Try reloading the ecobee integration."
}
},
"issues": {
diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py
index 89ee433c072..c92082b7b58 100644
--- a/homeassistant/components/ecobee/switch.py
+++ b/homeassistant/components/ecobee/switch.py
@@ -8,14 +8,13 @@ from typing import Any
from homeassistant.components.climate import HVACMode
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
-from . import EcobeeData
+from . import EcobeeConfigEntry, EcobeeData
from .climate import HASS_TO_ECOBEE_HVAC
-from .const import DOMAIN, ECOBEE_AUX_HEAT_ONLY
+from .const import ECOBEE_AUX_HEAT_ONLY
from .entity import EcobeeBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -25,11 +24,11 @@ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat switch entity."""
- data: EcobeeData = hass.data[DOMAIN]
+ data = config_entry.runtime_data
entities: list[SwitchEntity] = [
EcobeeVentilator20MinSwitch(
diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py
index b6378504c65..39b2d30ddd8 100644
--- a/homeassistant/components/ecobee/weather.py
+++ b/homeassistant/components/ecobee/weather.py
@@ -17,7 +17,6 @@ from homeassistant.components.weather import (
WeatherEntity,
WeatherEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfLength,
UnitOfPressure,
@@ -29,6 +28,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
+from . import EcobeeConfigEntry
from .const import (
DOMAIN,
ECOBEE_MODEL_TO_NAME,
@@ -39,11 +39,11 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcobeeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee weather platform."""
- data = hass.data[DOMAIN]
+ data = config_entry.runtime_data
dev = []
for index in range(len(data.ecobee.thermostats)):
thermostat = data.ecobee.get_thermostat(index)
diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py
index 4d5aaa40576..e5350beba8e 100644
--- a/homeassistant/components/ecoforest/__init__.py
+++ b/homeassistant/components/ecoforest/__init__.py
@@ -11,20 +11,18 @@ from pyecoforest.exceptions import (
EcoforestConnectionError,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN
-from .coordinator import EcoforestCoordinator
+from .coordinator import EcoforestConfigEntry, EcoforestCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> bool:
"""Set up Ecoforest from a config entry."""
host = entry.data[CONF_HOST]
@@ -41,20 +39,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Error communicating with device %s", host)
raise ConfigEntryNotReady from err
- coordinator = EcoforestCoordinator(hass, api)
+ coordinator = EcoforestCoordinator(hass, entry, api)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py
index 3b04325bd50..603fde38388 100644
--- a/homeassistant/components/ecoforest/coordinator.py
+++ b/homeassistant/components/ecoforest/coordinator.py
@@ -6,6 +6,7 @@ from pyecoforest.api import EcoforestApi
from pyecoforest.exceptions import EcoforestError
from pyecoforest.models.device import Device
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -13,16 +14,21 @@ from .const import POLLING_INTERVAL
_LOGGER = logging.getLogger(__name__)
+type EcoforestConfigEntry = ConfigEntry[EcoforestCoordinator]
+
class EcoforestCoordinator(DataUpdateCoordinator[Device]):
"""DataUpdateCoordinator to gather data from ecoforest device."""
- def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry: EcoforestConfigEntry, api: EcoforestApi
+ ) -> None:
"""Initialize DataUpdateCoordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name="ecoforest",
update_interval=POLLING_INTERVAL,
)
diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py
index db3275c1fcc..878c150343e 100644
--- a/homeassistant/components/ecoforest/number.py
+++ b/homeassistant/components/ecoforest/number.py
@@ -8,12 +8,10 @@ from dataclasses import dataclass
from pyecoforest.models.device import Device
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-from .coordinator import EcoforestCoordinator
+from .coordinator import EcoforestConfigEntry
from .entity import EcoforestEntity
@@ -38,11 +36,11 @@ NUMBER_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcoforestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Ecoforest number platform."""
- coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
entities = [
EcoforestNumberEntity(coordinator, description)
diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py
index 997b02436cc..0babb476ab6 100644
--- a/homeassistant/components/ecoforest/sensor.py
+++ b/homeassistant/components/ecoforest/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UnitOfPressure,
@@ -24,8 +23,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import DOMAIN
-from .coordinator import EcoforestCoordinator
+from .coordinator import EcoforestConfigEntry
from .entity import EcoforestEntity
_LOGGER = logging.getLogger(__name__)
@@ -143,10 +141,12 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EcoforestConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Ecoforest sensor platform."""
- coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
entities = [
EcoforestSensor(coordinator, description) for description in SENSOR_TYPES
diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py
index d643217bebc..de52248e751 100644
--- a/homeassistant/components/ecoforest/switch.py
+++ b/homeassistant/components/ecoforest/switch.py
@@ -10,12 +10,10 @@ from pyecoforest.api import EcoforestApi
from pyecoforest.models.device import Device
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-from .coordinator import EcoforestCoordinator
+from .coordinator import EcoforestConfigEntry
from .entity import EcoforestEntity
@@ -39,11 +37,11 @@ SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EcoforestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Ecoforest switch platform."""
- coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
entities = [
EcoforestSwitchEntity(coordinator, description) for description in SWITCH_TYPES
diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py
index 4fd920a5ecc..40bece93599 100644
--- a/homeassistant/components/econet/__init__.py
+++ b/homeassistant/components/econet/__init__.py
@@ -6,7 +6,7 @@ import logging
from aiohttp.client_exceptions import ClientError
from pyeconet import EcoNetApiInterface
-from pyeconet.equipment import EquipmentType
+from pyeconet.equipment import Equipment, EquipmentType
from pyeconet.errors import (
GenericHTTPError,
InvalidCredentialsError,
@@ -21,7 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
-from .const import API_CLIENT, DOMAIN, EQUIPMENT, PUSH_UPDATE
+from .const import PUSH_UPDATE
_LOGGER = logging.getLogger(__name__)
@@ -36,7 +36,12 @@ PLATFORMS = [
INTERVAL = timedelta(minutes=60)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+type EconetConfigEntry = ConfigEntry[dict[EquipmentType, list[Equipment]]]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: EconetConfigEntry
+) -> bool:
"""Set up EcoNet as config entry."""
email = config_entry.data[CONF_EMAIL]
@@ -57,9 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
)
except (ClientError, GenericHTTPError, InvalidResponseFormat) as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {API_CLIENT: {}, EQUIPMENT: {}})
- hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api
- hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment
+
+ config_entry.runtime_data = equipment
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@@ -89,10 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EconetConfigEntry) -> bool:
"""Unload a EcoNet config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN][API_CLIENT].pop(entry.entry_id)
- hass.data[DOMAIN][EQUIPMENT].pop(entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py
index 0f5cb6f92af..13ef8c4713b 100644
--- a/homeassistant/components/econet/binary_sensor.py
+++ b/homeassistant/components/econet/binary_sensor.py
@@ -2,18 +2,17 @@
from __future__ import annotations
-from pyeconet.equipment import EquipmentType
+from pyeconet.equipment import Equipment, EquipmentType
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, EQUIPMENT
+from . import EconetConfigEntry
from .entity import EcoNetEntity
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
@@ -41,10 +40,12 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EconetConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EcoNet binary sensor based on a config entry."""
- equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id]
+ equipment = entry.runtime_data
all_equipment = equipment[EquipmentType.WATER_HEATER].copy()
all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy())
@@ -62,7 +63,7 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity):
"""Define a Econet binary sensor."""
def __init__(
- self, econet_device, description: BinarySensorEntityDescription
+ self, econet_device: Equipment, description: BinarySensorEntityDescription
) -> None:
"""Initialize."""
super().__init__(econet_device)
diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py
index cdf82f6817f..d46dbd8750a 100644
--- a/homeassistant/components/econet/climate.py
+++ b/homeassistant/components/econet/climate.py
@@ -3,7 +3,11 @@
from typing import Any
from pyeconet.equipment import EquipmentType
-from pyeconet.equipment.thermostat import ThermostatFanMode, ThermostatOperationMode
+from pyeconet.equipment.thermostat import (
+ Thermostat,
+ ThermostatFanMode,
+ ThermostatOperationMode,
+)
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
@@ -16,13 +20,13 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from .const import DOMAIN, EQUIPMENT
+from . import EconetConfigEntry
+from .const import DOMAIN
from .entity import EcoNetEntity
ECONET_STATE_TO_HA = {
@@ -51,10 +55,12 @@ SUPPORT_FLAGS_THERMOSTAT = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EconetConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EcoNet thermostat based on a config entry."""
- equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id]
+ equipment = entry.runtime_data
async_add_entities(
[
EcoNetThermostat(thermostat)
@@ -63,13 +69,13 @@ async def async_setup_entry(
)
-class EcoNetThermostat(EcoNetEntity, ClimateEntity):
+class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
"""Define an Econet thermostat."""
_attr_should_poll = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
- def __init__(self, thermostat):
+ def __init__(self, thermostat: Thermostat) -> None:
"""Initialize."""
super().__init__(thermostat)
self._attr_hvac_modes = []
@@ -90,24 +96,24 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity):
)
@property
- def current_temperature(self):
+ def current_temperature(self) -> int:
"""Return the current temperature."""
return self._econet.set_point
@property
- def current_humidity(self):
+ def current_humidity(self) -> int:
"""Return the current humidity."""
return self._econet.humidity
@property
- def target_humidity(self):
+ def target_humidity(self) -> int | None:
"""Return the humidity we try to reach."""
if self._econet.supports_humidifier:
return self._econet.dehumidifier_set_point
return None
@property
- def target_temperature(self):
+ def target_temperature(self) -> int | None:
"""Return the temperature we try to reach."""
if self.hvac_mode == HVACMode.COOL:
return self._econet.cool_set_point
@@ -116,14 +122,14 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity):
return None
@property
- def target_temperature_low(self):
+ def target_temperature_low(self) -> int | None:
"""Return the lower bound temperature we try to reach."""
if self.hvac_mode == HVACMode.HEAT_COOL:
return self._econet.heat_set_point
return None
@property
- def target_temperature_high(self):
+ def target_temperature_high(self) -> int | None:
"""Return the higher bound temperature we try to reach."""
if self.hvac_mode == HVACMode.HEAT_COOL:
return self._econet.cool_set_point
@@ -140,7 +146,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity):
self._econet.set_set_point(None, target_temp_high, target_temp_low)
@property
- def is_aux_heat(self):
+ def is_aux_heat(self) -> bool:
"""Return true if aux heater."""
return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT
@@ -169,7 +175,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity):
self._econet.set_dehumidifier_set_point(humidity)
@property
- def fan_mode(self):
+ def fan_mode(self) -> str:
"""Return the current fan mode."""
econet_fan_mode = self._econet.fan_mode
@@ -183,7 +189,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity):
return _current_fan_mode
@property
- def fan_modes(self):
+ def fan_modes(self) -> list[str]:
"""Return the fan modes."""
return [
ECONET_FAN_STATE_TO_HA[mode]
diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py
index ee8d4fc8a46..78384f7683d 100644
--- a/homeassistant/components/econet/const.py
+++ b/homeassistant/components/econet/const.py
@@ -1,7 +1,5 @@
"""Constants for Econet integration."""
DOMAIN = "econet"
-API_CLIENT = "api_client"
-EQUIPMENT = "equipment"
PUSH_UPDATE = "econet.push_update"
diff --git a/homeassistant/components/econet/entity.py b/homeassistant/components/econet/entity.py
index 44488f0b133..2ec8af83dd0 100644
--- a/homeassistant/components/econet/entity.py
+++ b/homeassistant/components/econet/entity.py
@@ -1,5 +1,7 @@
"""Support for EcoNet products."""
+from pyeconet.equipment import Equipment
+
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -8,18 +10,18 @@ from homeassistant.helpers.entity import Entity
from .const import DOMAIN, PUSH_UPDATE
-class EcoNetEntity(Entity):
+class EcoNetEntity[_EquipmentT: Equipment = Equipment](Entity):
"""Define a base EcoNet entity."""
_attr_should_poll = False
- def __init__(self, econet):
+ def __init__(self, econet: _EquipmentT) -> None:
"""Initialize."""
self._econet = econet
self._attr_name = econet.device_name
self._attr_unique_id = f"{econet.device_id}_{econet.device_name}"
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Subscribe to device events."""
await super().async_added_to_hass()
self.async_on_remove(
@@ -27,12 +29,12 @@ class EcoNetEntity(Entity):
)
@callback
- def on_update_received(self):
+ def on_update_received(self) -> None:
"""Update was pushed from the ecoent API."""
self.async_write_ha_state()
@property
- def available(self):
+ def available(self) -> bool:
"""Return if the device is online or not."""
return self._econet.connected
diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py
index 19bac8c9e1f..510906d699c 100644
--- a/homeassistant/components/econet/sensor.py
+++ b/homeassistant/components/econet/sensor.py
@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
@@ -21,7 +20,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, EQUIPMENT
+from . import EconetConfigEntry
from .entity import EcoNetEntity
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -82,11 +81,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EconetConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EcoNet sensor based on a config entry."""
- data = hass.data[DOMAIN][EQUIPMENT][entry.entry_id]
+ data = entry.runtime_data
equipment = data[EquipmentType.WATER_HEATER].copy()
equipment.extend(data[EquipmentType.THERMOSTAT].copy())
diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py
index e36f6c834b1..9fcd38c860e 100644
--- a/homeassistant/components/econet/switch.py
+++ b/homeassistant/components/econet/switch.py
@@ -6,14 +6,13 @@ import logging
from typing import Any
from pyeconet.equipment import EquipmentType
-from pyeconet.equipment.thermostat import ThermostatOperationMode
+from pyeconet.equipment.thermostat import Thermostat, ThermostatOperationMode
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, EQUIPMENT
+from . import EconetConfigEntry
from .entity import EcoNetEntity
_LOGGER = logging.getLogger(__name__)
@@ -21,21 +20,21 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: EconetConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat switch entity."""
- equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id]
+ equipment = entry.runtime_data
async_add_entities(
EcoNetSwitchAuxHeatOnly(thermostat)
for thermostat in equipment[EquipmentType.THERMOSTAT]
)
-class EcoNetSwitchAuxHeatOnly(EcoNetEntity, SwitchEntity):
+class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity):
"""Representation of a aux_heat_only EcoNet switch."""
- def __init__(self, thermostat) -> None:
+ def __init__(self, thermostat: Thermostat) -> None:
"""Initialize EcoNet ventilator platform."""
super().__init__(thermostat)
self._attr_name = f"{thermostat.device_name} emergency heat"
diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py
index efe4196993c..cfbff70b580 100644
--- a/homeassistant/components/econet/water_heater.py
+++ b/homeassistant/components/econet/water_heater.py
@@ -5,7 +5,7 @@ import logging
from typing import Any
from pyeconet.equipment import EquipmentType
-from pyeconet.equipment.water_heater import WaterHeaterOperationMode
+from pyeconet.equipment.water_heater import WaterHeater, WaterHeaterOperationMode
from homeassistant.components.water_heater import (
STATE_ECO,
@@ -17,12 +17,11 @@ from homeassistant.components.water_heater import (
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, EQUIPMENT
+from . import EconetConfigEntry
from .entity import EcoNetEntity
SCAN_INTERVAL = timedelta(hours=1)
@@ -47,10 +46,12 @@ SUPPORT_FLAGS_HEATER = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EconetConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EcoNet water heater based on a config entry."""
- equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id]
+ equipment = entry.runtime_data
async_add_entities(
[
EcoNetWaterHeater(water_heater)
@@ -60,24 +61,24 @@ async def async_setup_entry(
)
-class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity):
+class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
"""Define an Econet water heater."""
_attr_should_poll = True # Override False default from EcoNetEntity
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
- def __init__(self, water_heater):
+ def __init__(self, water_heater: WaterHeater) -> None:
"""Initialize."""
super().__init__(water_heater)
self.water_heater = water_heater
@property
- def is_away_mode_on(self):
+ def is_away_mode_on(self) -> bool:
"""Return true if away mode is on."""
return self._econet.away
@property
- def current_operation(self):
+ def current_operation(self) -> str:
"""Return current operation."""
econet_mode = self.water_heater.mode
_current_op = STATE_OFF
@@ -87,7 +88,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity):
return _current_op
@property
- def operation_list(self):
+ def operation_list(self) -> list[str]:
"""List of available operation modes."""
econet_modes = self.water_heater.modes
op_list = []
@@ -130,7 +131,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity):
_LOGGER.error("Invalid operation mode: %s", operation_mode)
@property
- def target_temperature(self):
+ def target_temperature(self) -> int:
"""Return the temperature we try to reach."""
return self.water_heater.set_point
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 157d5b4a5ea..33a251c22dc 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==11.0.0"]
+ "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"]
}
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
index dde4fd64b56..bc78981d1db 100644
--- a/homeassistant/components/ecovacs/vacuum.py
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -163,11 +163,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
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)
-
return data
def return_to_base(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py
index 5dc30a575d7..1047c52e111 100644
--- a/homeassistant/components/eddystone_temperature/sensor.py
+++ b/homeassistant/components/eddystone_temperature/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import Event, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py
index e0d063eb9fd..5482143fc37 100644
--- a/homeassistant/components/edimax/switch.py
+++ b/homeassistant/components/edimax/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py
index 4474893d9b6..62d06a8a535 100644
--- a/homeassistant/components/edl21/sensor.py
+++ b/homeassistant/components/edl21/sensor.py
@@ -292,8 +292,8 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the EDL21 sensor."""
- hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities)
- await hass.data[DOMAIN].connect()
+ api = EDL21(hass, config_entry.data, async_add_entities)
+ await api.connect()
class EDL21:
diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py
index 89dae7d23c9..eb6b4cd49d8 100644
--- a/homeassistant/components/egardia/__init__.py
+++ b/homeassistant/components/egardia/__init__.py
@@ -16,8 +16,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py
index cf08f45bed5..a555a87cfbc 100644
--- a/homeassistant/components/eheimdigital/__init__.py
+++ b/homeassistant/components/eheimdigital/__init__.py
@@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
from .coordinator import EheimDigitalUpdateCoordinator
-PLATFORMS = [Platform.LIGHT]
+PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator]
diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py
new file mode 100644
index 00000000000..7ad06659089
--- /dev/null
+++ b/homeassistant/components/eheimdigital/climate.py
@@ -0,0 +1,147 @@
+"""EHEIM Digital climate."""
+
+from typing import Any
+
+from eheimdigital.device import EheimDigitalDevice
+from eheimdigital.heater import EheimDigitalHeater
+from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
+
+from homeassistant.components.climate import (
+ PRESET_NONE,
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.const import (
+ ATTR_TEMPERATURE,
+ PRECISION_HALVES,
+ PRECISION_TENTHS,
+ UnitOfTemperature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import EheimDigitalConfigEntry
+from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE
+from .coordinator import EheimDigitalUpdateCoordinator
+from .entity import EheimDigitalEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: EheimDigitalConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
+ coordinator = entry.runtime_data
+
+ def async_setup_device_entities(
+ device_address: str | dict[str, EheimDigitalDevice],
+ ) -> None:
+ """Set up the climate entities for one or multiple devices."""
+ entities: list[EheimDigitalHeaterClimate] = []
+ if isinstance(device_address, str):
+ device_address = {device_address: coordinator.hub.devices[device_address]}
+ for device in device_address.values():
+ if isinstance(device, EheimDigitalHeater):
+ entities.append(EheimDigitalHeaterClimate(coordinator, device))
+ coordinator.known_devices.add(device.mac_address)
+
+ async_add_entities(entities)
+
+ coordinator.add_platform_callback(async_setup_device_entities)
+
+ async_setup_device_entities(coordinator.hub.devices)
+
+
+class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
+ """Represent an EHEIM Digital heater."""
+
+ _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO]
+ _attr_hvac_mode = HVACMode.OFF
+ _attr_precision = PRECISION_TENTHS
+ _attr_supported_features = (
+ ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.TURN_ON
+ | ClimateEntityFeature.TURN_OFF
+ | ClimateEntityFeature.PRESET_MODE
+ )
+ _attr_target_temperature_step = PRECISION_HALVES
+ _attr_preset_modes = [PRESET_NONE, HEATER_BIO_MODE, HEATER_SMART_MODE]
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_preset_mode = PRESET_NONE
+ _attr_translation_key = "heater"
+ _attr_name = None
+
+ def __init__(
+ self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater
+ ) -> None:
+ """Initialize an EHEIM Digital thermocontrol climate entity."""
+ super().__init__(coordinator, device)
+ self._attr_unique_id = self._device_address
+ self._async_update_attrs()
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set the preset mode."""
+ try:
+ if preset_mode in HEATER_PRESET_TO_HEATER_MODE:
+ await self._device.set_operation_mode(
+ HEATER_PRESET_TO_HEATER_MODE[preset_mode]
+ )
+ except EheimDigitalClientError as err:
+ raise HomeAssistantError from err
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set a new temperature."""
+ try:
+ if ATTR_TEMPERATURE in kwargs:
+ await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE])
+ except EheimDigitalClientError as err:
+ raise HomeAssistantError from err
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set the heating mode."""
+ try:
+ match hvac_mode:
+ case HVACMode.OFF:
+ await self._device.set_active(active=False)
+ case HVACMode.AUTO:
+ await self._device.set_active(active=True)
+ except EheimDigitalClientError as err:
+ raise HomeAssistantError from err
+
+ def _async_update_attrs(self) -> None:
+ if self._device.temperature_unit == HeaterUnit.CELSIUS:
+ self._attr_min_temp = 18
+ self._attr_max_temp = 32
+ self._attr_temperature_unit = UnitOfTemperature.CELSIUS
+ elif self._device.temperature_unit == HeaterUnit.FAHRENHEIT:
+ self._attr_min_temp = 64
+ self._attr_max_temp = 90
+ self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
+
+ self._attr_current_temperature = self._device.current_temperature
+ self._attr_target_temperature = self._device.target_temperature
+
+ if self._device.is_heating:
+ self._attr_hvac_action = HVACAction.HEATING
+ self._attr_hvac_mode = HVACMode.AUTO
+ elif self._device.is_active:
+ self._attr_hvac_action = HVACAction.IDLE
+ self._attr_hvac_mode = HVACMode.AUTO
+ else:
+ self._attr_hvac_action = HVACAction.OFF
+ self._attr_hvac_mode = HVACMode.OFF
+
+ match self._device.operation_mode:
+ case HeaterMode.MANUAL:
+ self._attr_preset_mode = PRESET_NONE
+ case HeaterMode.BIO:
+ self._attr_preset_mode = HEATER_BIO_MODE
+ case HeaterMode.SMART:
+ self._attr_preset_mode = HEATER_SMART_MODE
diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py
index 6994c6f65b5..c6535608b0c 100644
--- a/homeassistant/components/eheimdigital/config_flow.py
+++ b/homeassistant/components/eheimdigital/config_flow.py
@@ -10,11 +10,11 @@ from eheimdigital.device import EheimDigitalDevice
from eheimdigital.hub import EheimDigitalHub
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py
index 5ed9303be40..61b391b6c63 100644
--- a/homeassistant/components/eheimdigital/const.py
+++ b/homeassistant/components/eheimdigital/const.py
@@ -2,8 +2,9 @@
from logging import Logger, getLogger
-from eheimdigital.types import LightMode
+from eheimdigital.types import HeaterMode, LightMode
+from homeassistant.components.climate import PRESET_NONE
from homeassistant.components.light import EFFECT_OFF
LOGGER: Logger = getLogger(__package__)
@@ -15,3 +16,12 @@ EFFECT_TO_LIGHT_MODE = {
EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE,
EFFECT_OFF: LightMode.MAN_MODE,
}
+
+HEATER_BIO_MODE = "bio_mode"
+HEATER_SMART_MODE = "smart_mode"
+
+HEATER_PRESET_TO_HEATER_MODE = {
+ HEATER_BIO_MODE: HeaterMode.BIO,
+ HEATER_SMART_MODE: HeaterMode.SMART,
+ PRESET_NONE: HeaterMode.MANUAL,
+}
diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py
index f122a1227c5..ee4f09426b7 100644
--- a/homeassistant/components/eheimdigital/coordinator.py
+++ b/homeassistant/components/eheimdigital/coordinator.py
@@ -2,8 +2,7 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
-from typing import Any
+from collections.abc import Callable
from aiohttp import ClientError
from eheimdigital.device import EheimDigitalDevice
@@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
-type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]]
+type AsyncSetupDeviceEntitiesCallback = Callable[
+ [str | dict[str, EheimDigitalDevice]], None
+]
class EheimDigitalUpdateCoordinator(
@@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator(
if device_address not in self.known_devices:
for platform_callback in self.platform_callbacks:
- await platform_callback(device_address)
+ platform_callback(device_address)
async def _async_receive_callback(self) -> None:
self.async_set_updated_data(self.hub.devices)
diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py
index a119e0bda8d..5ae0a6e866a 100644
--- a/homeassistant/components/eheimdigital/light.py
+++ b/homeassistant/components/eheimdigital/light.py
@@ -3,6 +3,7 @@
from typing import Any
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
+from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import EheimDigitalClientError, LightMode
from homeassistant.components.light import (
@@ -37,24 +38,28 @@ async def async_setup_entry(
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
- async def async_setup_device_entities(device_address: str) -> None:
- """Set up the light entities for a device."""
- device = coordinator.hub.devices[device_address]
+ def async_setup_device_entities(
+ device_address: str | dict[str, EheimDigitalDevice],
+ ) -> None:
+ """Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalClassicLEDControlLight] = []
+ if isinstance(device_address, str):
+ device_address = {device_address: coordinator.hub.devices[device_address]}
+ for device in device_address.values():
+ if isinstance(device, EheimDigitalClassicLEDControl):
+ for channel in range(2):
+ if len(device.tankconfig[channel]) > 0:
+ entities.append(
+ EheimDigitalClassicLEDControlLight(
+ coordinator, device, channel
+ )
+ )
+ coordinator.known_devices.add(device.mac_address)
- if isinstance(device, EheimDigitalClassicLEDControl):
- for channel in range(2):
- if len(device.tankconfig[channel]) > 0:
- entities.append(
- EheimDigitalClassicLEDControlLight(coordinator, device, channel)
- )
- coordinator.known_devices.add(device.mac_address)
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
-
- for device_address in entry.runtime_data.hub.devices:
- await async_setup_device_entities(device_address)
+ async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalClassicLEDControlLight(
diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json
index 159aecd6b6c..7747ca4f95d 100644
--- a/homeassistant/components/eheimdigital/manifest.json
+++ b/homeassistant/components/eheimdigital/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
- "requirements": ["eheimdigital==1.0.3"],
+ "requirements": ["eheimdigital==1.0.5"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]
diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json
index 0e6fa6a0814..ef6f6b10d0a 100644
--- a/homeassistant/components/eheimdigital/strings.json
+++ b/homeassistant/components/eheimdigital/strings.json
@@ -23,6 +23,18 @@
}
},
"entity": {
+ "climate": {
+ "heater": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "bio_mode": "Bio mode",
+ "smart_mode": "Smart mode"
+ }
+ }
+ }
+ }
+ },
"light": {
"channel": {
"name": "Channel {channel_id}",
diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py
index b8e5eb1bdd8..27cebc9aee9 100644
--- a/homeassistant/components/electrasmart/__init__.py
+++ b/homeassistant/components/electrasmart/__init__.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-from typing import cast
-
from electrasmart.api import ElectraAPI, ElectraApiError
from homeassistant.config_entries import ConfigEntry
@@ -12,36 +10,40 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import CONF_IMEI, DOMAIN
+from .const import CONF_IMEI
PLATFORMS: list[Platform] = [Platform.CLIMATE]
+type ElectraSmartConfigEntry = ConfigEntry[ElectraAPI]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ElectraSmartConfigEntry
+) -> bool:
"""Set up Electra Smart Air Conditioner from a config entry."""
- hass.data.setdefault(DOMAIN, {})
- entry.async_on_unload(entry.add_update_listener(update_listener))
- hass.data[DOMAIN][entry.entry_id] = ElectraAPI(
+ api = ElectraAPI(
async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN]
)
-
try:
- await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices()
+ await api.fetch_devices()
except ElectraApiError as exp:
raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+ entry.runtime_data = api
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: ElectraSmartConfigEntry
+) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def update_listener(
+ hass: HomeAssistant, config_entry: ElectraSmartConfigEntry
+) -> None:
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py
index 04e4742554b..84def436dfb 100644
--- a/homeassistant/components/electrasmart/climate.py
+++ b/homeassistant/components/electrasmart/climate.py
@@ -24,13 +24,13 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import ElectraSmartConfigEntry
from .const import (
API_DELAY,
CONSECUTIVE_FAILURE_THRESHOLD,
@@ -89,10 +89,12 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ElectraSmartConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Add Electra AC devices."""
- api: ElectraAPI = hass.data[DOMAIN][entry.entry_id]
+ api = entry.runtime_data
_LOGGER.debug("Discovered %i Electra devices", len(api.devices))
async_add_entities(
diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py
index 8c9a0b3950e..de8d87553a3 100644
--- a/homeassistant/components/electric_kiwi/__init__.py
+++ b/homeassistant/components/electric_kiwi/__init__.py
@@ -6,23 +6,25 @@ import aiohttp
from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import api
-from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR
from .coordinator import (
ElectricKiwiAccountDataCoordinator,
+ ElectricKiwiConfigEntry,
ElectricKiwiHOPDataCoordinator,
+ ElectricKiwiRuntimeData,
)
PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ElectricKiwiConfigEntry
+) -> bool:
"""Set up Electric Kiwi from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -44,8 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
ek_api = ElectricKiwiApi(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
- hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api)
- account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api)
+ hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api)
+ account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api)
try:
await ek_api.set_active_session()
@@ -54,19 +56,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except ApiException as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
- HOP_COORDINATOR: hop_coordinator,
- ACCOUNT_COORDINATOR: account_coordinator,
- }
+ entry.runtime_data = ElectricKiwiRuntimeData(
+ hop=hop_coordinator, account=account_coordinator
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: ElectricKiwiConfigEntry
+) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py
index 0b455b045cf..907b6247172 100644
--- a/homeassistant/components/electric_kiwi/const.py
+++ b/homeassistant/components/electric_kiwi/const.py
@@ -9,6 +9,3 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token"
API_BASE_URL = "https://api.electrickiwi.co.nz"
SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session"
-
-HOP_COORDINATOR = "hop_coordinator"
-ACCOUNT_COORDINATOR = "account_coordinator"
diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py
index a10be5eafdd..2065da5d668 100644
--- a/homeassistant/components/electric_kiwi/coordinator.py
+++ b/homeassistant/components/electric_kiwi/coordinator.py
@@ -1,7 +1,10 @@
"""Electric Kiwi coordinators."""
+from __future__ import annotations
+
import asyncio
from collections import OrderedDict
+from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -9,6 +12,7 @@ from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.model import AccountBalance, Hop, HopIntervals
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -19,14 +23,31 @@ ACCOUNT_SCAN_INTERVAL = timedelta(hours=6)
HOP_SCAN_INTERVAL = timedelta(minutes=20)
+@dataclass
+class ElectricKiwiRuntimeData:
+ """ElectricKiwi runtime data."""
+
+ hop: ElectricKiwiHOPDataCoordinator
+ account: ElectricKiwiAccountDataCoordinator
+
+
+type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData]
+
+
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
"""ElectricKiwi Account Data object."""
- def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ElectricKiwiConfigEntry,
+ ek_api: ElectricKiwiApi,
+ ) -> None:
"""Initialize ElectricKiwiAccountDataCoordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name="Electric Kiwi Account Data",
update_interval=ACCOUNT_SCAN_INTERVAL,
)
@@ -48,11 +69,17 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
"""ElectricKiwi HOP Data object."""
- def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ElectricKiwiConfigEntry,
+ ek_api: ElectricKiwiApi,
+ ) -> None:
"""Initialize ElectricKiwiAccountDataCoordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
# Name of the data. For logging purposes.
name="Electric Kiwi HOP Data",
# Polling interval. Will only be polled if there are subscribers.
diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py
index a3f073b8ca2..fa111381612 100644
--- a/homeassistant/components/electric_kiwi/select.py
+++ b/homeassistant/components/electric_kiwi/select.py
@@ -5,14 +5,13 @@ from __future__ import annotations
import logging
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.config_entries import ConfigEntry
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 .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR
-from .coordinator import ElectricKiwiHOPDataCoordinator
+from .const import ATTRIBUTION
+from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator
_LOGGER = logging.getLogger(__name__)
ATTR_EK_HOP_SELECT = "hop_select"
@@ -25,12 +24,12 @@ HOP_SELECT = SelectEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ElectricKiwiConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Electric Kiwi select setup."""
- hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][
- HOP_COORDINATOR
- ]
+ hop_coordinator = entry.runtime_data.hop
_LOGGER.debug("Setting up select entity")
async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)])
diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py
index 7672466106b..e070f9495c1 100644
--- a/homeassistant/components/electric_kiwi/sensor.py
+++ b/homeassistant/components/electric_kiwi/sensor.py
@@ -14,16 +14,16 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
-from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR
+from .const import ATTRIBUTION
from .coordinator import (
ElectricKiwiAccountDataCoordinator,
+ ElectricKiwiConfigEntry,
ElectricKiwiHOPDataCoordinator,
)
@@ -122,12 +122,12 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ElectricKiwiConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Electric Kiwi Sensors Setup."""
- account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][
- entry.entry_id
- ][ACCOUNT_COORDINATOR]
+ account_coordinator = entry.runtime_data.account
entities: list[SensorEntity] = [
ElectricKiwiAccountEntity(
@@ -137,9 +137,7 @@ async def async_setup_entry(
for description in ACCOUNT_SENSOR_TYPES
]
- hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][
- HOP_COORDINATOR
- ]
+ hop_coordinator = entry.runtime_data.hop
entities.extend(
[
ElectricKiwiHOPEntity(hop_coordinator, description)
diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py
index 040d38d272c..1de92f95e43 100644
--- a/homeassistant/components/elevenlabs/const.py
+++ b/homeassistant/components/elevenlabs/const.py
@@ -1,5 +1,7 @@
"""Constants for the ElevenLabs text-to-speech integration."""
+ATTR_MODEL = "model"
+
CONF_VOICE = "voice"
CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice"
diff --git a/homeassistant/components/elevenlabs/quality_scale.yaml b/homeassistant/components/elevenlabs/quality_scale.yaml
index ecd2092492c..94c395310c5 100644
--- a/homeassistant/components/elevenlabs/quality_scale.yaml
+++ b/homeassistant/components/elevenlabs/quality_scale.yaml
@@ -13,7 +13,7 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: >
diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py
index b89e966593f..008cd106615 100644
--- a/homeassistant/components/elevenlabs/tts.py
+++ b/homeassistant/components/elevenlabs/tts.py
@@ -24,6 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ElevenLabsConfigEntry
from .const import (
+ ATTR_MODEL,
CONF_OPTIMIZE_LATENCY,
CONF_SIMILARITY,
CONF_STABILITY,
@@ -85,7 +86,7 @@ async def async_setup_entry(
class ElevenLabsTTSEntity(TextToSpeechEntity):
"""The ElevenLabs API entity."""
- _attr_supported_options = [ATTR_VOICE]
+ _attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
_attr_entity_category = EntityCategory.CONFIG
def __init__(
@@ -141,13 +142,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
_LOGGER.debug("Getting TTS audio for %s", message)
_LOGGER.debug("Options: %s", options)
voice_id = options.get(ATTR_VOICE, self._default_voice_id)
+ model = options.get(ATTR_MODEL, self._model.model_id)
try:
audio = await self._client.generate(
text=message,
voice=voice_id,
optimize_streaming_latency=self._latency,
voice_settings=self._voice_settings,
- model=self._model.model_id,
+ model=model,
)
bytes_combined = b"".join([byte_seg async for byte_seg in audio])
except ApiError as exc:
diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py
index e20afc73a2d..a47f039384c 100644
--- a/homeassistant/components/elgato/config_flow.py
+++ b/homeassistant/components/elgato/config_flow.py
@@ -7,11 +7,12 @@ from typing import Any
from elgato import Elgato, ElgatoError
import voluptuous as vol
-from homeassistant.components import onboarding, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -43,7 +44,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_create_entry()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.host = discovery_info.host
diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py
index 7c9f76824e8..1a5490da0a5 100644
--- a/homeassistant/components/eliqonline/sensor.py
+++ b/homeassistant/components/eliqonline/sensor.py
@@ -16,8 +16,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, UnitOfPower
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py
index 34a35fbeb09..5286b7ad66f 100644
--- a/homeassistant/components/elkm1/__init__.py
+++ b/homeassistant/components/elkm1/__init__.py
@@ -32,7 +32,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.network import is_ip_address
from .const import (
diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py
index f1ecf626263..ab51b6fe281 100644
--- a/homeassistant/components/elkm1/alarm_control_panel.py
+++ b/homeassistant/components/elkm1/alarm_control_panel.py
@@ -19,8 +19,7 @@ from homeassistant.components.alarm_control_panel import (
CodeFormat,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import VolDictType
diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py
index 854f8c56fb8..73f6b925e8c 100644
--- a/homeassistant/components/elkm1/binary_sensor.py
+++ b/homeassistant/components/elkm1/binary_sensor.py
@@ -49,7 +49,7 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity):
_element: Zone
_attr_entity_registry_enabled_default = False
- def _element_changed(self, _: Element, changeset: Any) -> None:
+ def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
# Zone in NORMAL state is OFF; any other state is ON
self._attr_is_on = bool(
self._element.logical_status != ZoneLogicalStatus.NORMAL
diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py
index a3dd1d46f8b..c486a385721 100644
--- a/homeassistant/components/elkm1/config_flow.py
+++ b/homeassistant/components/elkm1/config_flow.py
@@ -9,7 +9,6 @@ from elkm1_lib.discovery import ElkSystem
from elkm1_lib.elk import Elk
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_ADDRESS,
@@ -21,6 +20,7 @@ from homeassistant.const import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType
from homeassistant.util import slugify
from homeassistant.util.network import is_ip_address
@@ -140,7 +140,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_devices: dict[str, ElkSystem] = {}
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
self._discovered_device = ElkSystem(
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index e0231c86699..2ca932ec134 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -120,7 +120,7 @@ class ElkCounter(ElkSensor):
_attr_icon = "mdi:numeric"
_element: Counter
- def _element_changed(self, _: Element, changeset: Any) -> None:
+ def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
@@ -153,7 +153,7 @@ class ElkKeypad(ElkSensor):
attrs["last_keypress"] = self._element.last_keypress
return attrs
- def _element_changed(self, _: Element, changeset: Any) -> None:
+ def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPERATURE
)
@@ -173,7 +173,7 @@ class ElkPanel(ElkSensor):
attrs["system_trouble_status"] = self._element.system_trouble_status
return attrs
- def _element_changed(self, _: Element, changeset: Any) -> None:
+ def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
if self._elk.is_connected():
self._attr_native_value = (
"Paused" if self._element.remote_programming_status else "Connected"
@@ -188,7 +188,7 @@ class ElkSetting(ElkSensor):
_attr_translation_key = "setting"
_element: Setting
- def _element_changed(self, _: Element, changeset: Any) -> None:
+ def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
@property
@@ -257,7 +257,7 @@ class ElkZone(ElkSensor):
return UnitOfElectricPotential.VOLT
return None
- def _element_changed(self, _: Element, changeset: Any) -> None:
+ def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
if self._element.definition == ZoneType.TEMPERATURE:
self._attr_native_value = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPERATURE
diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json
index bf02d727280..f184483646d 100644
--- a/homeassistant/components/elkm1/strings.json
+++ b/homeassistant/components/elkm1/strings.json
@@ -89,7 +89,7 @@
},
"alarm_arm_vacation": {
"name": "Alarm arm vacation",
- "description": "Arm the ElkM1 in vacation mode.",
+ "description": "Arms the ElkM1 in vacation mode.",
"fields": {
"code": {
"name": "Code",
diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py
index d85e5778a39..ec293be8273 100644
--- a/homeassistant/components/elmax/__init__.py
+++ b/homeassistant/components/elmax/__init__.py
@@ -2,14 +2,10 @@
from __future__ import annotations
-from datetime import timedelta
-import logging
-
from elmax_api.exceptions import ElmaxBadLoginError
from elmax_api.http import Elmax, ElmaxLocal, GenericElmax
from elmax_api.model.panel import PanelEntry
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -27,17 +23,13 @@ from .const import (
CONF_ELMAX_PANEL_PIN,
CONF_ELMAX_PASSWORD,
CONF_ELMAX_USERNAME,
- DOMAIN,
ELMAX_PLATFORMS,
- POLLING_SECONDS,
)
-from .coordinator import ElmaxCoordinator
-
-_LOGGER = logging.getLogger(__name__)
+from .coordinator import ElmaxConfigEntry, ElmaxCoordinator
async def _load_elmax_panel_client(
- entry: ConfigEntry,
+ entry: ElmaxConfigEntry,
) -> tuple[GenericElmax, PanelEntry]:
# Connection mode was not present in initial version, default to cloud if not set
mode = entry.data.get(CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD)
@@ -87,7 +79,7 @@ async def _check_cloud_panel_status(client: Elmax, panel_id: str) -> PanelEntry:
return panel
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool:
"""Set up elmax-cloud from a config entry."""
try:
client, panel = await _load_elmax_panel_client(entry)
@@ -98,11 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# if there is something wrong with user credentials
coordinator = ElmaxCoordinator(
hass=hass,
- logger=_LOGGER,
+ entry=entry,
elmax_api_client=client,
panel=panel,
- name=f"Elmax Cloud {entry.entry_id}",
- update_interval=timedelta(seconds=POLLING_SECONDS),
)
async def _async_on_hass_stop(_: Event) -> None:
@@ -117,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
# Store a global reference to the coordinator for later use
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
@@ -126,15 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_reload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS)
diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py
index 841b94a3d72..139c9080c15 100644
--- a/homeassistant/components/elmax/alarm_control_panel.py
+++ b/homeassistant/components/elmax/alarm_control_panel.py
@@ -13,23 +13,22 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, InvalidStateError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
-from .coordinator import ElmaxCoordinator
+from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ElmaxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Elmax area platform."""
- coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
known_devices = set()
def _discover_new_devices():
diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py
index ec51f861819..351c386a084 100644
--- a/homeassistant/components/elmax/binary_sensor.py
+++ b/homeassistant/components/elmax/binary_sensor.py
@@ -8,22 +8,20 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ElmaxCoordinator
+from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ElmaxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Elmax sensor platform."""
- coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
known_devices = set()
def _discover_new_devices():
diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py
index 09e0bc0d260..b8697552626 100644
--- a/homeassistant/components/elmax/config_flow.py
+++ b/homeassistant/components/elmax/config_flow.py
@@ -12,9 +12,9 @@ from elmax_api.model.panel import PanelEntry, PanelStatus
import httpx
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .common import (
build_direct_ssl_context,
diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py
index 844a3413089..abcc098359e 100644
--- a/homeassistant/components/elmax/coordinator.py
+++ b/homeassistant/components/elmax/coordinator.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
-from logging import Logger
+import logging
from elmax_api.exceptions import (
ElmaxApiError,
@@ -22,11 +22,16 @@ from elmax_api.model.panel import PanelEntry, PanelStatus
from elmax_api.push.push import PushNotificationHandler
from httpx import ConnectError, ConnectTimeout
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DEFAULT_TIMEOUT
+from .const import DEFAULT_TIMEOUT, POLLING_SECONDS
+
+_LOGGER = logging.getLogger(__name__)
+
+type ElmaxConfigEntry = ConfigEntry[ElmaxCoordinator]
class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]):
@@ -37,11 +42,9 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]):
def __init__(
self,
hass: HomeAssistant,
- logger: Logger,
+ entry: ElmaxConfigEntry,
elmax_api_client: GenericElmax,
panel: PanelEntry,
- name: str,
- update_interval: timedelta,
) -> None:
"""Instantiate the object."""
self._client = elmax_api_client
@@ -49,7 +52,11 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]):
self._state_by_endpoint = {}
self._push_notification_handler = None
super().__init__(
- hass=hass, logger=logger, name=name, update_interval=update_interval
+ hass=hass,
+ config_entry=entry,
+ logger=_LOGGER,
+ name=f"Elmax Cloud {entry.entry_id}",
+ update_interval=timedelta(seconds=POLLING_SECONDS),
)
@property
diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py
index 403bc51dbff..e98477fe496 100644
--- a/homeassistant/components/elmax/cover.py
+++ b/homeassistant/components/elmax/cover.py
@@ -9,12 +9,10 @@ from elmax_api.model.command import CoverCommand
from elmax_api.model.cover_status import CoverStatus
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ElmaxCoordinator
+from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
_LOGGER = logging.getLogger(__name__)
@@ -28,11 +26,11 @@ _COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover mo
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ElmaxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Elmax cover platform."""
- coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
# Add the cover feature only if supported by the current panel.
if coordinator.data is None or not coordinator.data.cover_feature:
return
diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py
index d0e52c556f6..70faa44cf01 100644
--- a/homeassistant/components/elmax/switch.py
+++ b/homeassistant/components/elmax/switch.py
@@ -8,12 +8,10 @@ from elmax_api.model.command import SwitchCommand
from elmax_api.model.panel import PanelStatus
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ElmaxCoordinator
+from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
_LOGGER = logging.getLogger(__name__)
@@ -21,11 +19,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ElmaxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Elmax switch platform."""
- coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
known_devices = set()
def _discover_new_devices():
diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py
index 208d19a0f8e..97f08c786f4 100644
--- a/homeassistant/components/elv/__init__.py
+++ b/homeassistant/components/elv/__init__.py
@@ -4,8 +4,7 @@ import voluptuous as vol
from homeassistant.const import CONF_DEVICE, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
DOMAIN = "elv"
diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py
index 21ee6449c11..812e58ecc19 100644
--- a/homeassistant/components/emby/media_player.py
+++ b/homeassistant/components/emby/media_player.py
@@ -24,10 +24,10 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py
index 0cd686b5b56..581948bbc6f 100644
--- a/homeassistant/components/emoncms/__init__.py
+++ b/homeassistant/components/emoncms/__init__.py
@@ -26,7 +26,7 @@ def _migrate_unique_id(
for entity in entry_entities:
if entity.unique_id.split("-")[0] == entry.entry_id:
feed_id = entity.unique_id.split("-")[-1]
- LOGGER.debug(f"moving feed {feed_id} to hardware uuid")
+ LOGGER.debug("moving feed %s to hardware uuid", feed_id)
ent_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}"
)
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
index 291ecad0bd3..1920e06a8e8 100644
--- a/homeassistant/components/emoncms/sensor.py
+++ b/homeassistant/components/emoncms/sensor.py
@@ -38,8 +38,7 @@ from homeassistant.const import (
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import template
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json
index 5769e825944..77216a3fb2f 100644
--- a/homeassistant/components/emoncms/strings.json
+++ b/homeassistant/components/emoncms/strings.json
@@ -10,8 +10,8 @@
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
- "url": "Server url starting with the protocol (http or https)",
- "api_key": "Your 32 bits api key"
+ "url": "Server URL starting with the protocol (http or https)",
+ "api_key": "Your 32 bits API key"
}
},
"choose_feeds": {
@@ -93,7 +93,7 @@
},
"migrate_database": {
"title": "Upgrade your emoncms version",
- "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})"
+ "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\nPlease upgrade to at least version 11.5.7 and migrate your emoncms database.\n\nMore info in the [emoncms documentation]({doc_url})"
}
}
}
diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py
index 00af1fec6c6..2ab00d6ca42 100644
--- a/homeassistant/components/emoncms_history/__init__.py
+++ b/homeassistant/components/emoncms_history/__init__.py
@@ -16,8 +16,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.event import track_point_in_time
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py
index 833b80f9d47..458eb5ae3c7 100644
--- a/homeassistant/components/emonitor/config_flow.py
+++ b/homeassistant/components/emonitor/config_flow.py
@@ -7,12 +7,12 @@ from aioemonitor import Emonitor
import aiohttp
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import name_short_mac
from .const import DOMAIN
@@ -69,7 +69,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
self.discovered_ip = discovery_info.ip
diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py
index 3e229d07b6c..556831496c6 100644
--- a/homeassistant/components/emulated_hue/__init__.py
+++ b/homeassistant/components/emulated_hue/__init__.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .config import (
diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py
index e13112f20bb..464d2bcb7e7 100644
--- a/homeassistant/components/emulated_hue/hue_api.py
+++ b/homeassistant/components/emulated_hue/hue_api.py
@@ -865,7 +865,7 @@ def state_supports_hue_brightness(
return False
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
enum = ENTITY_FEATURES_BY_DOMAIN[domain]
- features = enum(features) if type(features) is int else features # noqa: E721
+ features = enum(features) if type(features) is int else features
return required_feature in features
diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py
index 408d8c4eff8..11f4ce80490 100644
--- a/homeassistant/components/emulated_kasa/__init__.py
+++ b/homeassistant/components/emulated_kasa/__init__.py
@@ -15,8 +15,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.template import Template, is_template_string
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py
index 4ebd31730bf..d4466f47ef2 100644
--- a/homeassistant/components/emulated_roku/__init__.py
+++ b/homeassistant/components/emulated_roku/__init__.py
@@ -7,7 +7,7 @@ from homeassistant.components.network import async_get_source_ip
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .binding import EmulatedRoku
diff --git a/homeassistant/components/energenie_power_sockets/__init__.py b/homeassistant/components/energenie_power_sockets/__init__.py
index 12ddb0d1389..0496c6f9b92 100644
--- a/homeassistant/components/energenie_power_sockets/__init__.py
+++ b/homeassistant/components/energenie_power_sockets/__init__.py
@@ -8,12 +8,14 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
-from .const import CONF_DEVICE_API_ID, DOMAIN
+from .const import CONF_DEVICE_API_ID
PLATFORMS = [Platform.SWITCH]
+type EnergenieConfigEntry = ConfigEntry[PowerStripUSB]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool:
"""Set up Energenie Power Sockets."""
try:
powerstrip: PowerStripUSB | None = get_device(entry.data[CONF_DEVICE_API_ID])
@@ -26,19 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"Can't access Energenie Power Sockets, will retry later."
)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = powerstrip
+ entry.runtime_data = powerstrip
+ entry.async_on_unload(powerstrip.release)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool:
"""Unload config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- powerstrip = hass.data[DOMAIN].pop(entry.entry_id)
- powerstrip.release()
-
- if not hass.data[DOMAIN]:
- hass.data.pop(DOMAIN)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json
index e193b06b25f..4e4e49c68fb 100644
--- a/homeassistant/components/energenie_power_sockets/strings.json
+++ b/homeassistant/components/energenie_power_sockets/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Searching for Energenie-Power-Sockets Devices.",
+ "title": "Searching for Energenie Power Sockets devices",
"description": "Choose a discovered device.",
"data": {
"device": "[%key:common::config_flow::data::device%]"
@@ -13,7 +13,7 @@
"abort": {
"usb_error": "Couldn't access USB devices!",
"no_device": "Unable to discover any (new) supported device.",
- "device_not_found": "No device was found for the given id.",
+ "device_not_found": "No device was found for the given ID.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py
index 1d5b9ed5197..e4fb7653e5e 100644
--- a/homeassistant/components/energenie_power_sockets/switch.py
+++ b/homeassistant/components/energenie_power_sockets/switch.py
@@ -7,22 +7,22 @@ from pyegps.exceptions import EgpsException
from pyegps.powerstrip import PowerStrip
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import EnergenieConfigEntry
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EnergenieConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add EGPS sockets for passed config_entry in HA."""
- powerstrip: PowerStrip = hass.data[DOMAIN][config_entry.entry_id]
+ powerstrip = config_entry.runtime_data
async_add_entities(
(
diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py
index 147d8f3e26a..eec92c32f98 100644
--- a/homeassistant/components/energy/sensor.py
+++ b/homeassistant/components/energy/sensor.py
@@ -29,8 +29,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import unit_conversion
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, unit_conversion
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DOMAIN
@@ -362,12 +361,11 @@ class EnergyCostSensor(SensorEntity):
return
if (
- (
- state_class != SensorStateClass.TOTAL_INCREASING
- and energy_state.attributes.get(ATTR_LAST_RESET)
- != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET)
- )
- or state_class == SensorStateClass.TOTAL_INCREASING
+ state_class != SensorStateClass.TOTAL_INCREASING
+ and energy_state.attributes.get(ATTR_LAST_RESET)
+ != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET)
+ ) or (
+ state_class == SensorStateClass.TOTAL_INCREASING
and reset_detected(
self.hass,
cast(str, self._config[self._adapter.stat_energy_key]),
diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py
index ee0de15c3fb..1012997ff7f 100644
--- a/homeassistant/components/enigma2/media_player.py
+++ b/homeassistant/components/enigma2/media_player.py
@@ -56,6 +56,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.SELECT_SOURCE
+ | MediaPlayerEntityFeature.PLAY
)
def __init__(self, coordinator: Enigma2UpdateCoordinator) -> None:
diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py
index 6dcec5ec218..c1db27c1c34 100644
--- a/homeassistant/components/enocean/__init__.py
+++ b/homeassistant/components/enocean/__init__.py
@@ -5,7 +5,7 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DATA_ENOCEAN, DOMAIN, ENOCEAN_DONGLE
@@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
- """Unload ENOcean config entry."""
+ """Unload EnOcean config entry."""
enocean_dongle = hass.data[DATA_ENOCEAN][ENOCEAN_DONGLE]
enocean_dongle.unload()
diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py
index 01e39f96510..26039036ca0 100644
--- a/homeassistant/components/enocean/binary_sensor.py
+++ b/homeassistant/components/enocean/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py
index 2452d27b168..fd25b0c6ce1 100644
--- a/homeassistant/components/enocean/config_flow.py
+++ b/homeassistant/components/enocean/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flows for the ENOcean integration."""
+"""Config flows for the EnOcean integration."""
from typing import Any
@@ -6,6 +6,11 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
+from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
from . import dongle
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
@@ -15,7 +20,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle the enOcean config flows."""
VERSION = 1
- MANUAL_PATH_VALUE = "Custom path"
+ MANUAL_PATH_VALUE = "manual"
def __init__(self) -> None:
"""Initialize the EnOcean config flow."""
@@ -52,14 +57,24 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
return self.create_enocean_entry(user_input)
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
- bridges = await self.hass.async_add_executor_job(dongle.detect)
- if len(bridges) == 0:
+ devices = await self.hass.async_add_executor_job(dongle.detect)
+ if len(devices) == 0:
return await self.async_step_manual(user_input)
+ devices.append(self.MANUAL_PATH_VALUE)
- bridges.append(self.MANUAL_PATH_VALUE)
return self.async_show_form(
step_id="detect",
- data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(bridges)}),
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_DEVICE): SelectSelector(
+ SelectSelectorConfig(
+ options=devices,
+ translation_key="devices",
+ mode=SelectSelectorMode.LIST,
+ )
+ )
+ }
+ ),
errors=errors,
)
diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py
index 3624493b42e..0f3271655d8 100644
--- a/homeassistant/components/enocean/const.py
+++ b/homeassistant/components/enocean/const.py
@@ -1,4 +1,4 @@
-"""Constants for the ENOcean integration."""
+"""Constants for the EnOcean integration."""
import logging
diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py
index 2d9a3f8787e..43214b12064 100644
--- a/homeassistant/components/enocean/dongle.py
+++ b/homeassistant/components/enocean/dongle.py
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
class EnOceanDongle:
"""Representation of an EnOcean dongle.
- The dongle is responsible for receiving the ENOcean frames,
+ The dongle is responsible for receiving the EnOcean frames,
creating devices if needed, and dispatching messages to platforms.
"""
@@ -53,7 +53,7 @@ class EnOceanDongle:
def callback(self, packet):
"""Handle EnOcean device's callback.
- This is the callback function called by python-enocan whenever there
+ This is the callback function called by python-enocean whenever there
is an incoming packet.
"""
@@ -63,7 +63,7 @@ class EnOceanDongle:
def detect():
- """Return a list of candidate paths for USB ENOcean dongles.
+ """Return a list of candidate paths for USB EnOcean dongles.
This method is currently a bit simplistic, it may need to be
improved to support more configurations and OS.
diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py
index aae84e73848..6586714c1b6 100644
--- a/homeassistant/components/enocean/light.py
+++ b/homeassistant/components/enocean/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py
index 98e32ce1a4f..2a4b9364d81 100644
--- a/homeassistant/components/enocean/sensor.py
+++ b/homeassistant/components/enocean/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json
index 9d9699481b1..9baf4386eda 100644
--- a/homeassistant/components/enocean/strings.json
+++ b/homeassistant/components/enocean/strings.json
@@ -1,16 +1,23 @@
{
"config": {
+ "flow_title": "{name}",
"step": {
"detect": {
- "title": "Select the path to your ENOcean dongle",
+ "description": "Select your EnOcean USB dongle.",
"data": {
- "path": "USB dongle path"
+ "device": "USB dongle"
+ },
+ "data_description": {
+ "device": "Path to your EnOcean USB dongle."
}
},
"manual": {
- "title": "Enter the path to your ENOcean dongle",
+ "description": "Enter the path to your EnOcean USB dongle.",
"data": {
- "path": "[%key:component::enocean::config::step::detect::data::path%]"
+ "device": "[%key:component::enocean::config::step::detect::data::device%]"
+ },
+ "data_description": {
+ "device": "[%key:component::enocean::config::step::detect::data_description::device%]"
}
}
},
@@ -20,5 +27,12 @@
"abort": {
"invalid_dongle_path": "Invalid dongle path"
}
+ },
+ "selector": {
+ "devices": {
+ "options": {
+ "manual": "Custom path"
+ }
+ }
}
}
diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py
index cdbb7080674..ba4aedf5013 100644
--- a/homeassistant/components/enphase_envoy/__init__.py
+++ b/homeassistant/components/enphase_envoy/__init__.py
@@ -79,6 +79,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
"""Unload a config entry."""
coordinator = entry.runtime_data
coordinator.async_cancel_token_refresh()
+ coordinator.async_cancel_firmware_refresh()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py
index 1ad6f259de1..0258281661a 100644
--- a/homeassistant/components/enphase_envoy/binary_sensor.py
+++ b/homeassistant/components/enphase_envoy/binary_sensor.py
@@ -67,7 +67,6 @@ ENPOWER_SENSORS = (
EnvoyEnpowerBinarySensorEntityDescription(
key="mains_oper_state",
translation_key="grid_status",
- icon="mdi:transmission-tower",
value_fn=lambda enpower: enpower.mains_oper_state == "closed",
),
)
diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py
index 1a2186d305e..654e2262730 100644
--- a/homeassistant/components/enphase_envoy/config_flow.py
+++ b/homeassistant/components/enphase_envoy/config_flow.py
@@ -10,10 +10,8 @@ from awesomeversion import AwesomeVersion
from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
- ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -21,6 +19,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -43,12 +42,28 @@ INSTALLER_AUTH_USERNAME = "installer"
async def validate_input(
- hass: HomeAssistant, host: str, username: str, password: str
+ hass: HomeAssistant,
+ host: str,
+ username: str,
+ password: str,
+ errors: dict[str, str],
+ description_placeholders: dict[str, str],
) -> Envoy:
"""Validate the user input allows us to connect."""
envoy = Envoy(host, get_async_client(hass, verify_ssl=False))
- await envoy.setup()
- await envoy.authenticate(username=username, password=password)
+ try:
+ await envoy.setup()
+ await envoy.authenticate(username=username, password=password)
+ except INVALID_AUTH_ERRORS as e:
+ errors["base"] = "invalid_auth"
+ description_placeholders["reason"] = str(e)
+ except EnvoyError as e:
+ errors["base"] = "cannot_connect"
+ description_placeholders["reason"] = str(e)
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
return envoy
@@ -57,8 +72,6 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _reauth_entry: ConfigEntry
-
def __init__(self) -> None:
"""Initialize an envoy flow."""
self.ip_address: str | None = None
@@ -110,7 +123,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
}
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
if _LOGGER.isEnabledFor(logging.DEBUG):
@@ -159,10 +172,43 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
- self._reauth_entry = self._get_reauth_entry()
- if unique_id := self._reauth_entry.unique_id:
- await self.async_set_unique_id(unique_id, raise_on_progress=False)
- return await self.async_step_user()
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that reauth is required."""
+ reauth_entry = self._get_reauth_entry()
+ errors: dict[str, str] = {}
+ description_placeholders: dict[str, str] = {}
+
+ if user_input is not None:
+ await validate_input(
+ self.hass,
+ reauth_entry.data[CONF_HOST],
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ errors,
+ description_placeholders,
+ )
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates=user_input,
+ )
+
+ serial = reauth_entry.unique_id or "-"
+ self.context["title_placeholders"] = {
+ CONF_SERIAL: serial,
+ CONF_HOST: reauth_entry.data[CONF_HOST],
+ }
+ description_placeholders["serial"] = serial
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=self._async_generate_schema(),
+ description_placeholders=description_placeholders,
+ errors=errors,
+ )
def _async_envoy_name(self) -> str:
"""Return the name of the envoy."""
@@ -174,38 +220,20 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
-
- if self.source == SOURCE_REAUTH:
- host = self._reauth_entry.data[CONF_HOST]
- else:
- host = (user_input or {}).get(CONF_HOST) or self.ip_address or ""
+ host = (user_input or {}).get(CONF_HOST) or self.ip_address or ""
if user_input is not None:
- try:
- envoy = await validate_input(
- self.hass,
- host,
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
- )
- except INVALID_AUTH_ERRORS as e:
- errors["base"] = "invalid_auth"
- description_placeholders = {"reason": str(e)}
- except EnvoyError as e:
- errors["base"] = "cannot_connect"
- description_placeholders = {"reason": str(e)}
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
+ envoy = await validate_input(
+ self.hass,
+ host,
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ errors,
+ description_placeholders,
+ )
+ if not errors:
name = self._async_envoy_name()
- if self.source == SOURCE_REAUTH:
- return self.async_update_reload_and_abort(
- self._reauth_entry,
- data=self._reauth_entry.data | user_input,
- )
-
if not self.unique_id:
await self.async_set_unique_id(envoy.serial_number)
name = self._async_envoy_name()
@@ -251,23 +279,15 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
host: str = user_input[CONF_HOST]
username: str = user_input[CONF_USERNAME]
password: str = user_input[CONF_PASSWORD]
- try:
- envoy = await validate_input(
- self.hass,
- host,
- username,
- password,
- )
- except INVALID_AUTH_ERRORS as e:
- errors["base"] = "invalid_auth"
- description_placeholders = {"reason": str(e)}
- except EnvoyError as e:
- errors["base"] = "cannot_connect"
- description_placeholders = {"reason": str(e)}
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
+ envoy = await validate_input(
+ self.hass,
+ host,
+ username,
+ password,
+ errors,
+ description_placeholders,
+ )
+ if not errors:
await self.async_set_unique_id(envoy.serial_number)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
@@ -279,10 +299,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
+ serial = reconfigure_entry.unique_id or "-"
self.context["title_placeholders"] = {
- CONF_SERIAL: reconfigure_entry.unique_id or "-",
+ CONF_SERIAL: serial,
CONF_HOST: reconfigure_entry.data[CONF_HOST],
}
+ description_placeholders["serial"] = serial
suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
return self.async_show_form(
diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py
index 67f43ca64a8..8eb2b32ac7b 100644
--- a/homeassistant/components/enphase_envoy/coordinator.py
+++ b/homeassistant/components/enphase_envoy/coordinator.py
@@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, INVALID_AUTH_ERRORS
@@ -25,6 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=60)
TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1)
STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds()
NOTIFICATION_ID = "enphase_envoy_notification"
+FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4)
_LOGGER = logging.getLogger(__name__)
@@ -50,6 +51,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._setup_complete = False
self.envoy_firmware = ""
self._cancel_token_refresh: CALLBACK_TYPE | None = None
+ self._cancel_firmware_refresh: CALLBACK_TYPE | None = None
super().__init__(
hass,
_LOGGER,
@@ -87,10 +89,48 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return
self._async_update_saved_token()
+ @callback
+ def _async_refresh_firmware(self, now: datetime.datetime) -> None:
+ """Proactively check for firmware changes in Envoy."""
+ self.hass.async_create_background_task(
+ self._async_try_refresh_firmware(), "{name} firmware refresh"
+ )
+
+ async def _async_try_refresh_firmware(self) -> None:
+ """Check firmware in Envoy and reload config entry if changed."""
+ # envoy.setup just reads firmware, serial and partnumber from /info
+ try:
+ await self.envoy.setup()
+ except EnvoyError as err:
+ # just try again next time
+ _LOGGER.debug("%s: Error reading firmware: %s", err, self.name)
+ return
+ if (current_firmware := self.envoy_firmware) and current_firmware != (
+ new_firmware := self.envoy.firmware
+ ):
+ self.envoy_firmware = new_firmware
+ _LOGGER.warning(
+ "Envoy firmware changed from: %s to: %s, reloading config entry %s",
+ current_firmware,
+ new_firmware,
+ self.name,
+ )
+ # reload the integration to get all established again
+ self.hass.async_create_task(
+ self.hass.config_entries.async_reload(self.config_entry.entry_id)
+ )
+
@callback
def _async_mark_setup_complete(self) -> None:
- """Mark setup as complete and setup token refresh if needed."""
+ """Mark setup as complete and setup firmware checks and token refresh if needed."""
self._setup_complete = True
+ self.async_cancel_firmware_refresh()
+ self._cancel_firmware_refresh = async_track_time_interval(
+ self.hass,
+ self._async_refresh_firmware,
+ FIRMWARE_REFRESH_INTERVAL,
+ cancel_on_shutdown=True,
+ )
self.async_cancel_token_refresh()
if not isinstance(self.envoy.auth, EnvoyTokenAuth):
return
@@ -204,3 +244,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._cancel_token_refresh:
self._cancel_token_refresh()
self._cancel_token_refresh = None
+
+ @callback
+ def async_cancel_firmware_refresh(self) -> None:
+ """Cancel firmware refresh."""
+ if self._cancel_firmware_refresh:
+ self._cancel_firmware_refresh()
+ self._cancel_firmware_refresh = None
diff --git a/homeassistant/components/enphase_envoy/icons.json b/homeassistant/components/enphase_envoy/icons.json
new file mode 100644
index 00000000000..21262d1dc89
--- /dev/null
+++ b/homeassistant/components/enphase_envoy/icons.json
@@ -0,0 +1,58 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "grid_status": {
+ "default": "mdi:transmission-tower",
+ "state": {
+ "off": "mdi:transmission-tower-off"
+ }
+ }
+ },
+ "sensor": {
+ "current_power_production": {
+ "default": "mdi:solar-power"
+ },
+ "daily_production": {
+ "default": "mdi:solar-power"
+ },
+ "seven_days_production": {
+ "default": "mdi:solar-power"
+ },
+ "lifetime_production": {
+ "default": "mdi:solar-power"
+ },
+ "current_power_production_phase": {
+ "default": "mdi:solar-power"
+ },
+ "daily_production_phase": {
+ "default": "mdi:solar-power"
+ },
+ "seven_days_production_phase": {
+ "default": "mdi:solar-power"
+ },
+ "lifetime_production_phase": {
+ "default": "mdi:solar-power"
+ },
+ "max_capacity": {
+ "default": "mdi:battery-charging-100"
+ },
+ "available_energy": {
+ "default": "mdi:battery-50"
+ }
+ },
+ "switch": {
+ "grid_enabled": {
+ "default": "mdi:transmission-tower",
+ "state": {
+ "off": "mdi:transmission-tower-off"
+ }
+ },
+ "relay_status": {
+ "default": "mdi:electric-switch-closed",
+ "state": {
+ "off": "mdi:electric-switch"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index bdc90e6c634..0b1fd8b04b9 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.23.0"],
+ "requirements": ["pyenphase==1.23.1"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml
index a7038b4e0da..4431a298c8c 100644
--- a/homeassistant/components/enphase_envoy/quality_scale.yaml
+++ b/homeassistant/components/enphase_envoy/quality_scale.yaml
@@ -8,15 +8,8 @@ rules:
comment: fixed 1 minute cycle based on Enphase Envoy device characteristics
brands: done
common-modules: done
- config-flow-test-coverage:
- status: todo
- comment: |
- - test_zero_conf_malformed_serial_property - with pytest.raises(KeyError) as ex::
- I don't believe this should be able to raise a KeyError Shouldn't we abort the flow?
- config-flow:
- status: todo
- comment: |
- - async_step_reaut L160: I believe that the unique is already set when starting a reauth flow
+ config-flow-test-coverage: done
+ config-flow: done
dependency-transparency: done
docs-actions:
status: done
@@ -60,11 +53,7 @@ rules:
status: done
comment: pending https://github.com/home-assistant/core/pull/132373
reauthentication-flow: done
- test-coverage:
- status: todo
- comment: |
- - test_config_different_unique_id -> unique_id set to the mock config entry is an int, not a str
- - Apart from the coverage, test_option_change_reload does not verify that the config entry is reloaded
+ test-coverage: done
# Gold
devices: done
@@ -87,12 +76,12 @@ rules:
comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting
docs-use-cases: todo
dynamic-devices: todo
- entity-category: todo
+ entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
- icon-translations: todo
+ icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py
index d9729a16683..7dc275aab37 100644
--- a/homeassistant/components/enphase_envoy/select.py
+++ b/homeassistant/components/enphase_envoy/select.py
@@ -37,7 +37,7 @@ class EnvoyRelaySelectEntityDescription(SelectEntityDescription):
class EnvoyStorageSettingsSelectEntityDescription(SelectEntityDescription):
"""Describes an Envoy storage settings select entity."""
- value_fn: Callable[[EnvoyStorageSettings], str]
+ value_fn: Callable[[EnvoyStorageSettings], str | None]
update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]]
@@ -118,7 +118,9 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription(
key="storage_mode",
translation_key="storage_mode",
options=STORAGE_MODE_OPTIONS,
- value_fn=lambda storage_settings: STORAGE_MODE_MAP[storage_settings.mode],
+ value_fn=lambda storage_settings: (
+ None if not storage_settings.mode else STORAGE_MODE_MAP[storage_settings.mode]
+ ),
update_fn=lambda envoy, value: envoy.set_storage_mode(
REVERSE_STORAGE_MODE_MAP[value]
),
@@ -235,7 +237,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
)
@property
- def current_option(self) -> str:
+ def current_option(self) -> str | None:
"""Return the state of the select entity."""
assert self.data.tariff is not None
assert self.data.tariff.storage_settings is not None
diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py
index 62ae5b621ac..dcf062a5417 100644
--- a/homeassistant/components/enphase_envoy/sensor.py
+++ b/homeassistant/components/enphase_envoy/sensor.py
@@ -37,6 +37,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
PERCENTAGE,
+ EntityCategory,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -55,7 +56,6 @@ from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
-ICON = "mdi:flash"
_LOGGER = logging.getLogger(__name__)
INVERTERS_KEY = "inverters"
@@ -370,6 +370,7 @@ CT_NET_CONSUMPTION_SENSORS = (
key="net_consumption_ct_metering_status",
translation_key="net_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
@@ -379,6 +380,7 @@ CT_NET_CONSUMPTION_SENSORS = (
key="net_consumption_ct_status_flags",
translation_key="net_ct_status_flags",
state_class=None,
+ entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
@@ -452,6 +454,7 @@ CT_PRODUCTION_SENSORS = (
translation_key="production_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
+ entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
@@ -460,6 +463,7 @@ CT_PRODUCTION_SENSORS = (
key="production_ct_status_flags",
translation_key="production_ct_status_flags",
state_class=None,
+ entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
@@ -565,6 +569,7 @@ CT_STORAGE_SENSORS = (
translation_key="storage_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
+ entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
@@ -573,6 +578,7 @@ CT_STORAGE_SENSORS = (
key="storage_ct_status_flags",
translation_key="storage_ct_status_flags",
state_class=None,
+ entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
@@ -946,8 +952,6 @@ class EnvoySensorBaseEntity(EnvoyBaseEntity, SensorEntity):
class EnvoySystemSensorEntity(EnvoySensorBaseEntity):
"""Envoy system base entity."""
- _attr_icon = ICON
-
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
@@ -1174,7 +1178,6 @@ class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity):
class EnvoyInverterEntity(EnvoySensorBaseEntity):
"""Envoy inverter entity."""
- _attr_icon = ICON
entity_description: EnvoyInverterSensorEntityDescription
def __init__(
diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json
index a78d0bc032a..589dc52f71d 100644
--- a/homeassistant/components/enphase_envoy/strings.json
+++ b/homeassistant/components/enphase_envoy/strings.json
@@ -10,7 +10,9 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "The hostname or IP address of your Enphase Envoy gateway."
+ "host": "The hostname or IP address of your Enphase Envoy gateway.",
+ "username": "Installer or Enphase Cloud username",
+ "password": "Blank or Enphase Cloud password"
}
},
"reconfigure": {
@@ -21,7 +23,20 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]"
+ "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]",
+ "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]",
+ "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]"
+ }
+ },
+ "reauth_confirm": {
+ "description": "[%key:component::enphase_envoy::config::step::user::description%]",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]",
+ "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]"
}
}
},
@@ -44,6 +59,10 @@
"data": {
"diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
"disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares."
+ },
+ "data_description": {
+ "diagnostics_include_fixtures": "Include fixtures in diagnostics report",
+ "disable_keep_alive": "May resolve communication issues with some Envoy firmwares."
}
}
}
diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py
index 5170b694587..7074f341cc8 100644
--- a/homeassistant/components/enphase_envoy/switch.py
+++ b/homeassistant/components/enphase_envoy/switch.py
@@ -60,6 +60,7 @@ ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription(
RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription(
key="relay_status",
+ translation_key="relay_status",
value_fn=lambda dry_contact: dry_contact.status == DryContactStatus.CLOSED,
turn_on_fn=lambda envoy, id: envoy.close_dry_contact(id),
turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id),
diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py
index f88bb99cea0..8fa8a06e369 100644
--- a/homeassistant/components/entur_public_transport/sensor.py
+++ b/homeassistant/components/entur_public_transport/sensor.py
@@ -20,12 +20,11 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
API_CLIENT_NAME = "homeassistant-{}"
diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py
index 0b6eadf6d13..6afea2f983d 100644
--- a/homeassistant/components/environment_canada/__init__.py
+++ b/homeassistant/components/environment_canada/__init__.py
@@ -5,14 +5,12 @@ import logging
from env_canada import ECAirQuality, ECRadar, ECWeather
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from .const import CONF_STATION, DOMAIN
-from .coordinator import ECDataUpdateCoordinator
+from .const import CONF_STATION
+from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5)
DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5)
@@ -22,14 +20,13 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER]
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool:
"""Set up EC as config entry."""
lat = config_entry.data.get(CONF_LATITUDE)
lon = config_entry.data.get(CONF_LONGITUDE)
station = config_entry.data.get(CONF_STATION)
lang = config_entry.data.get(CONF_LANGUAGE, "English")
- coordinators = {}
errors = 0
weather_data = ECWeather(
@@ -37,31 +34,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
coordinates=(lat, lon),
language=lang.lower(),
)
- coordinators["weather_coordinator"] = ECDataUpdateCoordinator(
- hass, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL
+ weather_coordinator = ECDataUpdateCoordinator(
+ hass, config_entry, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL
)
try:
- await coordinators["weather_coordinator"].async_config_entry_first_refresh()
+ await weather_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
radar_data = ECRadar(coordinates=(lat, lon))
- coordinators["radar_coordinator"] = ECDataUpdateCoordinator(
- hass, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
+ radar_coordinator = ECDataUpdateCoordinator(
+ hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
try:
- await coordinators["radar_coordinator"].async_config_entry_first_refresh()
+ await radar_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada radar")
aqhi_data = ECAirQuality(coordinates=(lat, lon))
- coordinators["aqhi_coordinator"] = ECDataUpdateCoordinator(
- hass, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL
+ aqhi_coordinator = ECDataUpdateCoordinator(
+ hass, config_entry, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL
)
try:
- await coordinators["aqhi_coordinator"].async_config_entry_first_refresh()
+ await aqhi_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada AQHI")
@@ -69,31 +66,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if errors == 3:
raise ConfigEntryNotReady
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][config_entry.entry_id] = coordinators
+ config_entry.runtime_data = ECRuntimeData(
+ aqhi_coordinator=aqhi_coordinator,
+ radar_coordinator=radar_coordinator,
+ weather_coordinator=weather_coordinator,
+ )
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(
- config_entry, PLATFORMS
- )
-
- hass.data[DOMAIN].pop(config_entry.entry_id)
-
- return unload_ok
-
-
-def device_info(config_entry: ConfigEntry) -> DeviceInfo:
- """Build and return the device info for EC."""
- return DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, config_entry.entry_id)},
- manufacturer="Environment Canada",
- name=config_entry.title,
- configuration_url="https://weather.gc.ca/",
- )
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py
index 1625cd253da..3ba059e2206 100644
--- a/homeassistant/components/environment_canada/camera.py
+++ b/homeassistant/components/environment_canada/camera.py
@@ -2,10 +2,10 @@
from __future__ import annotations
+from env_canada import ECRadar
import voluptuous as vol
from homeassistant.components.camera import Camera
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
@@ -14,8 +14,8 @@ from homeassistant.helpers.entity_platform import (
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import device_info
-from .const import ATTR_OBSERVATION_TIME, DOMAIN
+from .const import ATTR_OBSERVATION_TIME
+from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
SERVICE_SET_RADAR_TYPE = "set_radar_type"
SET_RADAR_TYPE_SCHEMA: VolDictType = {
@@ -25,12 +25,12 @@ SET_RADAR_TYPE_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ECConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]["radar_coordinator"]
- async_add_entities([ECCamera(coordinator)])
+ coordinator = config_entry.runtime_data.radar_coordinator
+ async_add_entities([ECCameraEntity(coordinator)])
platform = async_get_current_platform()
platform.async_register_entity_service(
@@ -40,13 +40,13 @@ async def async_setup_entry(
)
-class ECCamera(CoordinatorEntity, Camera):
+class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
"""Implementation of an Environment Canada radar camera."""
_attr_has_entity_name = True
_attr_translation_key = "radar"
- def __init__(self, coordinator):
+ def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
"""Initialize the camera."""
super().__init__(coordinator)
Camera.__init__(self)
@@ -55,7 +55,7 @@ class ECCamera(CoordinatorEntity, Camera):
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar"
self._attr_attribution = self.radar_object.metadata["attribution"]
self._attr_entity_registry_enabled_default = False
- self._attr_device_info = device_info(coordinator.config_entry)
+ self._attr_device_info = coordinator.device_info
self.content_type = "image/gif"
diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py
index 8e77b309c78..e31e847cd2d 100644
--- a/homeassistant/components/environment_canada/coordinator.py
+++ b/homeassistant/components/environment_canada/coordinator.py
@@ -1,29 +1,67 @@
"""Coordinator for the Environment Canada (EC) component."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
import logging
import xml.etree.ElementTree as ET
-from env_canada import ec_exc
+from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+type ECConfigEntry = ConfigEntry[ECRuntimeData]
+type ECDataType = ECAirQuality | ECRadar | ECWeather
-class ECDataUpdateCoordinator(DataUpdateCoordinator):
+
+@dataclass
+class ECRuntimeData:
+ """Class to hold EC runtime data."""
+
+ aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
+ radar_coordinator: ECDataUpdateCoordinator[ECRadar]
+ weather_coordinator: ECDataUpdateCoordinator[ECWeather]
+
+
+class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]):
"""Class to manage fetching EC data."""
- def __init__(self, hass, ec_data, name, update_interval):
+ config_entry: ECConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ECConfigEntry,
+ ec_data: DataT,
+ name: str,
+ update_interval: timedelta,
+ ) -> None:
"""Initialize global EC data updater."""
super().__init__(
- hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=f"{DOMAIN} {name}",
+ update_interval=update_interval,
)
self.ec_data = ec_data
self.last_update_success = False
+ self.device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, entry.entry_id)},
+ manufacturer="Environment Canada",
+ configuration_url="https://weather.gc.ca/",
+ )
- async def _async_update_data(self):
+ async def _async_update_data(self) -> DataT:
"""Fetch data from EC."""
try:
await self.ec_data.update()
diff --git a/homeassistant/components/environment_canada/diagnostics.py b/homeassistant/components/environment_canada/diagnostics.py
index 0fb565fda59..024cca15f12 100644
--- a/homeassistant/components/environment_canada/diagnostics.py
+++ b/homeassistant/components/environment_canada/diagnostics.py
@@ -5,23 +5,21 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .coordinator import ECConfigEntry
TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: ECConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinators = hass.data[DOMAIN][config_entry.entry_id]
- weather_coord = coordinators["weather_coordinator"]
-
return {
"config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT),
- "weather_data": dict(weather_coord.ec_data.conditions),
+ "weather_data": dict(
+ config_entry.runtime_data.weather_coordinator.ec_data.conditions
+ ),
}
diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py
index 1a5d096203d..989667fb1ac 100644
--- a/homeassistant/components/environment_canada/sensor.py
+++ b/homeassistant/components/environment_canada/sensor.py
@@ -6,13 +6,14 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
+from env_canada import ECWeather
+
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_LOCATION,
DEGREE,
@@ -27,8 +28,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import device_info
-from .const import ATTR_STATION, DOMAIN
+from .const import ATTR_STATION
+from .coordinator import ECConfigEntry, ECDataType, ECDataUpdateCoordinator
ATTR_TIME = "alert time"
@@ -251,32 +252,44 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ECConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"]
- sensors: list[ECBaseSensor] = [ECSensor(coordinator, desc) for desc in SENSOR_TYPES]
- sensors.extend([ECAlertSensor(coordinator, desc) for desc in ALERT_TYPES])
- aqhi_coordinator = hass.data[DOMAIN][config_entry.entry_id]["aqhi_coordinator"]
- sensors.append(ECSensor(aqhi_coordinator, AQHI_SENSOR))
+ weather_coordinator = config_entry.runtime_data.weather_coordinator
+ sensors: list[ECBaseSensorEntity] = [
+ ECSensorEntity(weather_coordinator, desc) for desc in SENSOR_TYPES
+ ]
+ sensors.extend(
+ [ECAlertSensorEntity(weather_coordinator, desc) for desc in ALERT_TYPES]
+ )
+
+ sensors.append(
+ ECSensorEntity(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR)
+ )
async_add_entities(sensors)
-class ECBaseSensor(CoordinatorEntity, SensorEntity):
+class ECBaseSensorEntity[DataT: ECDataType](
+ CoordinatorEntity[ECDataUpdateCoordinator[DataT]], SensorEntity
+):
"""Environment Canada sensor base."""
entity_description: ECSensorEntityDescription
_attr_has_entity_name = True
- def __init__(self, coordinator, description):
+ def __init__(
+ self,
+ coordinator: ECDataUpdateCoordinator[DataT],
+ description: ECSensorEntityDescription,
+ ) -> None:
"""Initialize the base sensor."""
super().__init__(coordinator)
self.entity_description = description
self._ec_data = coordinator.ec_data
self._attr_attribution = self._ec_data.metadata["attribution"]
self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}"
- self._attr_device_info = device_info(coordinator.config_entry)
+ self._attr_device_info = coordinator.device_info
@property
def native_value(self):
@@ -287,10 +300,14 @@ class ECBaseSensor(CoordinatorEntity, SensorEntity):
return value
-class ECSensor(ECBaseSensor):
+class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]):
"""Environment Canada sensor for conditions."""
- def __init__(self, coordinator, description):
+ def __init__(
+ self,
+ coordinator: ECDataUpdateCoordinator[DataT],
+ description: ECSensorEntityDescription,
+ ) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, description)
self._attr_extra_state_attributes = {
@@ -299,7 +316,7 @@ class ECSensor(ECBaseSensor):
}
-class ECAlertSensor(ECBaseSensor):
+class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]):
"""Environment Canada sensor for alerts."""
@property
diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py
index 1871062c2e9..156b9f4152b 100644
--- a/homeassistant/components/environment_canada/weather.py
+++ b/homeassistant/components/environment_canada/weather.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
+from env_canada import ECWeather
+
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
@@ -27,7 +29,6 @@ from homeassistant.components.weather import (
SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfLength,
UnitOfPressure,
@@ -38,8 +39,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import device_info
from .const import DOMAIN
+from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/
# docs/current_conditions_icon_code_descriptions_e.csv
@@ -61,11 +62,10 @@ ICON_CONDITION_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ECConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"]
entity_registry = er.async_get(hass)
# Remove hourly entity from legacy config entries
@@ -76,7 +76,7 @@ async def async_setup_entry(
):
entity_registry.async_remove(hourly_entity_id)
- async_add_entities([ECWeather(coordinator)])
+ async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)])
def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str:
@@ -84,7 +84,9 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st
return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}"
-class ECWeather(SingleCoordinatorWeatherEntity):
+class ECWeatherEntity(
+ SingleCoordinatorWeatherEntity[ECDataUpdateCoordinator[ECWeather]]
+):
"""Representation of a weather condition."""
_attr_has_entity_name = True
@@ -96,7 +98,7 @@ class ECWeather(SingleCoordinatorWeatherEntity):
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
- def __init__(self, coordinator):
+ def __init__(self, coordinator: ECDataUpdateCoordinator[ECWeather]) -> None:
"""Initialize Environment Canada weather."""
super().__init__(coordinator)
self.ec_data = coordinator.ec_data
@@ -105,7 +107,7 @@ class ECWeather(SingleCoordinatorWeatherEntity):
self._attr_unique_id = _calculate_unique_id(
coordinator.config_entry.unique_id, False
)
- self._attr_device_info = device_info(coordinator.config_entry)
+ self._attr_device_info = coordinator.device_info
@property
def native_temperature(self):
diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py
index 0146b650c22..919704a6728 100644
--- a/homeassistant/components/envisalink/__init__.py
+++ b/homeassistant/components/envisalink/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py
index ce65178b8d8..9d1b6d0d7a1 100644
--- a/homeassistant/components/envisalink/alarm_control_panel.py
+++ b/homeassistant/components/envisalink/alarm_control_panel.py
@@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_CODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/envisalink/strings.json b/homeassistant/components/envisalink/strings.json
index a539c890169..265ce28f920 100644
--- a/homeassistant/components/envisalink/strings.json
+++ b/homeassistant/components/envisalink/strings.json
@@ -16,11 +16,11 @@
},
"invoke_custom_function": {
"name": "Invoke custom function",
- "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.\n.",
+ "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.",
"fields": {
"partition": {
"name": "Partition",
- "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\".\n."
+ "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\"."
},
"pgm": {
"name": "PGM",
diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py
index cedad8b76e2..f92be005db6 100644
--- a/homeassistant/components/ephember/climate.py
+++ b/homeassistant/components/ephember/climate.py
@@ -33,7 +33,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/epic_games_store/__init__.py b/homeassistant/components/epic_games_store/__init__.py
index af25eb98137..d9fb3bee529 100644
--- a/homeassistant/components/epic_games_store/__init__.py
+++ b/homeassistant/components/epic_games_store/__init__.py
@@ -2,34 +2,29 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import EGSCalendarUpdateCoordinator
+from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry
PLATFORMS: list[Platform] = [
Platform.CALENDAR,
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> bool:
"""Set up Epic Games Store from a config entry."""
coordinator = EGSCalendarUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py
index 2ebb381341e..5df1d6b756d 100644
--- a/homeassistant/components/epic_games_store/calendar.py
+++ b/homeassistant/components/epic_games_store/calendar.py
@@ -7,25 +7,24 @@ from datetime import datetime
from typing import Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, CalendarType
-from .coordinator import EGSCalendarUpdateCoordinator
+from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry
DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: EGSConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the local calendar platform."""
- coordinator: EGSCalendarUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
entities = [
EGSCalendar(coordinator, entry.entry_id, CalendarType.FREE),
diff --git a/homeassistant/components/epic_games_store/coordinator.py b/homeassistant/components/epic_games_store/coordinator.py
index d9c48f5da02..0653a3da9b3 100644
--- a/homeassistant/components/epic_games_store/coordinator.py
+++ b/homeassistant/components/epic_games_store/coordinator.py
@@ -20,13 +20,15 @@ SCAN_INTERVAL = timedelta(days=1)
_LOGGER = logging.getLogger(__name__)
+type EGSConfigEntry = ConfigEntry[EGSCalendarUpdateCoordinator]
+
class EGSCalendarUpdateCoordinator(
DataUpdateCoordinator[dict[str, list[dict[str, Any]]]]
):
"""Class to manage fetching data from the Epic Game Store."""
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, entry: EGSConfigEntry) -> None:
"""Initialize."""
self._api = EpicGamesStoreAPI(
entry.data[CONF_LANGUAGE],
@@ -37,6 +39,7 @@ class EGSCalendarUpdateCoordinator(
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py
index fec975c5098..c04c77f760d 100644
--- a/homeassistant/components/epion/__init__.py
+++ b/homeassistant/components/epion/__init__.py
@@ -4,30 +4,25 @@ from __future__ import annotations
from epion import Epion
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import EpionCoordinator
+from .coordinator import EpionConfigEntry, EpionCoordinator
PLATFORMS = [Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool:
"""Set up the Epion coordinator from a config entry."""
api = Epion(entry.data[CONF_API_KEY])
- coordinator = EpionCoordinator(hass, api)
+ coordinator = EpionCoordinator(hass, entry, api)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool:
"""Unload Epion config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py
index 3eb7efb5dc7..9eb31331097 100644
--- a/homeassistant/components/epion/coordinator.py
+++ b/homeassistant/components/epion/coordinator.py
@@ -5,6 +5,7 @@ from typing import Any
from epion import Epion, EpionAuthenticationError, EpionConnectionError
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -13,15 +14,20 @@ from .const import REFRESH_INTERVAL
_LOGGER = logging.getLogger(__name__)
+type EpionConfigEntry = ConfigEntry[EpionCoordinator]
+
class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Epion data update coordinator."""
- def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry: EpionConfigEntry, epion_api: Epion
+ ) -> None:
"""Initialize the Epion coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name="Epion",
update_interval=REFRESH_INTERVAL,
)
diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py
index 4717c095bfe..78027813ffa 100644
--- a/homeassistant/components/epion/sensor.py
+++ b/homeassistant/components/epion/sensor.py
@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
@@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import EpionCoordinator
+from .coordinator import EpionConfigEntry, EpionCoordinator
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -59,11 +58,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: EpionConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add an Epion entry."""
- coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
entities = [
EpionSensor(coordinator, epion_device_id, description)
diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py
index 715b55824b4..27dbaa93734 100644
--- a/homeassistant/components/epson/__init__.py
+++ b/homeassistant/components/epson/__init__.py
@@ -13,13 +13,15 @@ from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP
+from .const import CONF_CONNECTION_TYPE, HTTP
from .exceptions import CannotConnect, PoweredOff
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
+type EpsonConfigEntry = ConfigEntry[Projector]
+
async def validate_projector(
hass: HomeAssistant,
@@ -45,7 +47,7 @@ async def validate_projector(
return epson_proj
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool:
"""Set up epson from a config entry."""
projector = await validate_projector(
hass=hass,
@@ -54,23 +56,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
check_power=False,
check_powered_on=False,
)
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = projector
+ entry.runtime_data = projector
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(projector.close)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- projector = hass.data[DOMAIN].pop(entry.entry_id)
- projector.close()
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: EpsonConfigEntry
+) -> bool:
"""Migrate old entry."""
_LOGGER.debug(
"Migrating configuration from version %s.%s",
diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py
index a901e9df216..e0eac4a1cfb 100644
--- a/homeassistant/components/epson/media_player.py
+++ b/homeassistant/components/epson/media_player.py
@@ -45,6 +45,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import EpsonConfigEntry
from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE
_LOGGER = logging.getLogger(__name__)
@@ -52,13 +53,12 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EpsonConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Epson projector from a config entry."""
- projector: Projector = hass.data[DOMAIN][config_entry.entry_id]
projector_entity = EpsonProjectorMediaPlayer(
- projector=projector,
+ projector=config_entry.runtime_data,
unique_id=config_entry.unique_id or config_entry.entry_id,
entry=config_entry,
)
diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json
index 43f18d4fffc..e7db70acf5c 100644
--- a/homeassistant/components/eq3btsmart/manifest.json
+++ b/homeassistant/components/eq3btsmart/manifest.json
@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
- "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.0.0"]
+ "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"]
}
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
index 13e9496a9fd..fee2531fa20 100644
--- a/homeassistant/components/esphome/__init__.py
+++ b/homeassistant/components/esphome/__init__.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from aioesphomeapi import APIClient
from homeassistant.components import ffmpeg, zeroconf
+from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -12,7 +13,7 @@ from homeassistant.const import (
__version__ as ha_version,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN
@@ -86,4 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) ->
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
"""Remove an esphome config entry."""
+ if mac_address := entry.unique_id:
+ async_remove_scanner(hass, mac_address.upper())
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()
diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py
index 004bea1835d..27abb19909f 100644
--- a/homeassistant/components/esphome/bluetooth.py
+++ b/homeassistant/components/esphome/bluetooth.py
@@ -11,6 +11,7 @@ from bleak_esphome import connect_scanner
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
+from .const import DOMAIN
from .entry_data import RuntimeEntryData
@@ -27,6 +28,7 @@ def async_connect_scanner(
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
+ device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available)
@@ -38,7 +40,14 @@ def async_connect_scanner(
return partial(
_async_unload,
[
- async_register_scanner(hass, scanner),
+ async_register_scanner(
+ hass,
+ scanner,
+ source_domain=DOMAIN,
+ source_model=device_info.model,
+ source_config_entry_id=entry_data.entry_id,
+ source_device_id=device_id,
+ ),
scanner.async_setup(),
],
)
diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py
index cb892b314cd..695131b19f7 100644
--- a/homeassistant/components/esphome/config_flow.py
+++ b/homeassistant/components/esphome/config_flow.py
@@ -20,7 +20,7 @@ from aioesphomeapi import (
import aiohttp
import voluptuous as vol
-from homeassistant.components import dhcp, zeroconf
+from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
@@ -31,8 +31,10 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.json import json_loads_object
from .const import (
@@ -223,7 +225,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
mac_address: str | None = discovery_info.properties.get("mac")
@@ -293,7 +295,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py
index b0a37aefd0d..334c16e5730 100644
--- a/homeassistant/components/esphome/dashboard.py
+++ b/homeassistant/components/esphome/dashboard.py
@@ -12,6 +12,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
+from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .coordinator import ESPHomeDashboardCoordinator
@@ -19,7 +20,9 @@ from .coordinator import ESPHomeDashboardCoordinator
_LOGGER = logging.getLogger(__name__)
-KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager"
+KEY_DASHBOARD_MANAGER: HassKey[ESPHomeDashboardManager] = HassKey(
+ "esphome_dashboard_manager"
+)
STORAGE_KEY = "esphome.dashboard"
STORAGE_VERSION = 1
@@ -33,7 +36,7 @@ async def async_setup(hass: HomeAssistant) -> None:
await async_get_or_create_dashboard_manager(hass)
-@singleton(KEY_DASHBOARD_MANAGER)
+@singleton(KEY_DASHBOARD_MANAGER, async_=True)
async def async_get_or_create_dashboard_manager(
hass: HomeAssistant,
) -> ESPHomeDashboardManager:
@@ -140,7 +143,7 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboardCoordinator | No
where manager can be an asyncio.Event instead of the actual manager
because the singleton decorator is not yet done.
"""
- manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER)
+ manager = hass.data.get(KEY_DASHBOARD_MANAGER)
return manager.async_get() if manager else None
diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py
index 20d0d651bba..d1bb0bb77ff 100644
--- a/homeassistant/components/esphome/datetime.py
+++ b/homeassistant/components/esphome/datetime.py
@@ -8,7 +8,7 @@ from functools import partial
from aioesphomeapi import DateTimeInfo, DateTimeState
from homeassistant.components.datetime import DateTimeEntity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry
diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py
index 455a3f8d105..ff08e5f578a 100644
--- a/homeassistant/components/esphome/entity.py
+++ b/homeassistant/components/esphome/entity.py
@@ -19,9 +19,11 @@ import voluptuous as vol
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_platform,
+)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index dfd318c0c74..5f5ee1241f7 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -24,7 +24,7 @@ from aioesphomeapi import (
from awesomeversion import AwesomeVersion
import voluptuous as vol
-from homeassistant.components import tag, zeroconf
+from homeassistant.components import bluetooth, tag, zeroconf
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_MODE,
@@ -41,9 +41,11 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import template
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ template,
+)
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.issue_registry import (
@@ -134,16 +136,16 @@ class ESPHomeManager:
"""Class to manage an ESPHome connection."""
__slots__ = (
- "hass",
- "host",
- "password",
- "entry",
"cli",
"device_id",
"domain_data",
+ "entry",
+ "entry_data",
+ "hass",
+ "host",
+ "password",
"reconnect_logic",
"zeroconf_instance",
- "entry_data",
)
def __init__(
@@ -423,8 +425,12 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
- async_connect_scanner(hass, entry_data, cli, device_info)
+ async_connect_scanner(
+ hass, entry_data, cli, device_info, self.device_id
+ )
)
+ else:
+ bluetooth.async_remove_scanner(hass, device_info.mac_address)
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
@@ -567,7 +573,9 @@ def _async_setup_device_registry(
configuration_url = None
if device_info.webserver_port > 0:
- configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
+ entry_host = entry.data["host"]
+ host = f"[{entry_host}]" if ":" in entry_host else entry_host
+ configuration_url = f"http://{host}:{device_info.webserver_port}"
elif (
(dashboard := async_get_dashboard(hass))
and dashboard.data
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index b04fa4db428..1f8b505ec45 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -16,9 +16,9 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
- "aioesphomeapi==28.0.0",
+ "aioesphomeapi==29.0.0",
"esphome-dashboard-api==1.2.3",
- "bleak-esphome==2.0.0"
+ "bleak-esphome==2.7.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py
index e64b596a119..3e48307e8bf 100644
--- a/homeassistant/components/etherscan/sensor.py
+++ b/homeassistant/components/etherscan/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py
index 8ebe3e08843..57f90503049 100644
--- a/homeassistant/components/eufy/__init__.py
+++ b/homeassistant/components/eufy/__init__.py
@@ -14,8 +14,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
DOMAIN = "eufy"
diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py
index 95ad8a15d1c..dcce52612ee 100644
--- a/homeassistant/components/eufy/light.py
+++ b/homeassistant/components/eufy/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
EUFYHOME_MAX_KELVIN = 6500
EUFYHOME_MIN_KELVIN = 2700
diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py
index f66cf7df30d..8a58c50c8e4 100644
--- a/homeassistant/components/eufylife_ble/__init__.py
+++ b/homeassistant/components/eufylife_ble/__init__.py
@@ -6,17 +6,15 @@ from eufylife_ble_client import EufyLifeBLEDevice
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
-from .const import DOMAIN
-from .models import EufyLifeData
+from .models import EufyLifeConfigEntry, EufyLifeData
PLATFORMS: list[Platform] = [Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> bool:
"""Set up EufyLife device from a config entry."""
address = entry.unique_id
assert address is not None
@@ -45,11 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EufyLifeData(
- address,
- model,
- client,
- )
+ entry.runtime_data = EufyLifeData(address, model, client)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -63,9 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/eufylife_ble/models.py b/homeassistant/components/eufylife_ble/models.py
index eb937fc4f3d..26154a74fac 100644
--- a/homeassistant/components/eufylife_ble/models.py
+++ b/homeassistant/components/eufylife_ble/models.py
@@ -6,6 +6,10 @@ from dataclasses import dataclass
from eufylife_ble_client import EufyLifeBLEDevice
+from homeassistant.config_entries import ConfigEntry
+
+type EufyLifeConfigEntry = ConfigEntry[EufyLifeData]
+
@dataclass
class EufyLifeData:
diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py
index 5e3ae64aabf..d9cef45ce4d 100644
--- a/homeassistant/components/eufylife_ble/sensor.py
+++ b/homeassistant/components/eufylife_ble/sensor.py
@@ -6,7 +6,6 @@ from typing import Any
from eufylife_ble_client import MODEL_TO_NAME
-from homeassistant import config_entries
from homeassistant.components.bluetooth import async_address_present
from homeassistant.components.sensor import (
RestoreSensor,
@@ -20,19 +19,18 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
-from .const import DOMAIN
-from .models import EufyLifeData
+from .models import EufyLifeConfigEntry, EufyLifeData
IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN}
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
+ entry: EufyLifeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the EufyLife sensors."""
- data: EufyLifeData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities = [
EufyLifeWeightSensorEntity(data),
diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py
index c4a8fb2d0af..4ed5a0f1378 100644
--- a/homeassistant/components/event/__init__.py
+++ b/homeassistant/components/event/__init__.py
@@ -8,7 +8,7 @@ from enum import StrEnum
import logging
from typing import Any, Self, final
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -42,8 +42,8 @@ __all__ = [
"ATTR_EVENT_TYPE",
"ATTR_EVENT_TYPES",
"DOMAIN",
- "PLATFORM_SCHEMA_BASE",
"PLATFORM_SCHEMA",
+ "PLATFORM_SCHEMA_BASE",
"EventDeviceClass",
"EventEntity",
"EventEntityDescription",
diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py
index 2ba47978353..ae159d77240 100644
--- a/homeassistant/components/everlights/light.py
+++ b/homeassistant/components/everlights/light.py
@@ -21,11 +21,11 @@ from homeassistant.components.light import (
from homeassistant.const import CONF_HOSTS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py
index d5bc3a564a2..7fb7430a044 100644
--- a/homeassistant/components/evil_genius_labs/__init__.py
+++ b/homeassistant/components/evil_genius_labs/__init__.py
@@ -4,38 +4,32 @@ from __future__ import annotations
import pyevilgenius
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
-from .const import DOMAIN
-from .coordinator import EvilGeniusUpdateCoordinator
+from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator
PLATFORMS = [Platform.LIGHT]
UPDATE_INTERVAL = 10
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> bool:
"""Set up Evil Genius Labs from a config entry."""
coordinator = EvilGeniusUpdateCoordinator(
hass,
- entry.title,
+ entry,
pyevilgenius.EvilGeniusDevice(
entry.data["host"], aiohttp_client.async_get_clientsession(hass)
),
)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/evil_genius_labs/coordinator.py b/homeassistant/components/evil_genius_labs/coordinator.py
index 9f0f0df02af..202dcaf6ba7 100644
--- a/homeassistant/components/evil_genius_labs/coordinator.py
+++ b/homeassistant/components/evil_genius_labs/coordinator.py
@@ -10,11 +10,16 @@ from typing import cast
from aiohttp import ContentTypeError
import pyevilgenius
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
UPDATE_INTERVAL = 10
+_LOGGER = logging.getLogger(__name__)
+
+type EvilGeniusConfigEntry = ConfigEntry[EvilGeniusUpdateCoordinator]
+
class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]):
"""Update coordinator for Evil Genius data."""
@@ -24,14 +29,18 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]):
product: dict | None
def __init__(
- self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice
+ self,
+ hass: HomeAssistant,
+ entry: EvilGeniusConfigEntry,
+ client: pyevilgenius.EvilGeniusDevice,
) -> None:
"""Initialize the data update coordinator."""
self.client = client
super().__init__(
hass,
- logging.getLogger(__name__),
- name=name,
+ _LOGGER,
+ config_entry=entry,
+ name=entry.title,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
diff --git a/homeassistant/components/evil_genius_labs/diagnostics.py b/homeassistant/components/evil_genius_labs/diagnostics.py
index c9c79acc1bb..371e0c85b35 100644
--- a/homeassistant/components/evil_genius_labs/diagnostics.py
+++ b/homeassistant/components/evil_genius_labs/diagnostics.py
@@ -5,20 +5,18 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import EvilGeniusUpdateCoordinator
+from .coordinator import EvilGeniusConfigEntry
TO_REDACT = {"wiFiSsidDefault", "wiFiSSID"}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: EvilGeniusConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
return {
"info": async_redact_data(coordinator.info, TO_REDACT),
diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py
index 3556672dcce..a6d1d9531b5 100644
--- a/homeassistant/components/evil_genius_labs/light.py
+++ b/homeassistant/components/evil_genius_labs/light.py
@@ -7,12 +7,10 @@ from typing import Any, cast
from homeassistant.components import light
from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-from .coordinator import EvilGeniusUpdateCoordinator
+from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator
from .entity import EvilGeniusEntity
from .util import update_when_done
@@ -22,12 +20,11 @@ FIB_NO_EFFECT = "Solid Color"
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: EvilGeniusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Evil Genius light platform."""
- coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- async_add_entities([EvilGeniusLight(coordinator)])
+ async_add_entities([EvilGeniusLight(config_entry.runtime_data)])
class EvilGeniusLight(EvilGeniusEntity, LightEntity):
diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
index 612131919d4..97f7c2db54d 100644
--- a/homeassistant/components/evohome/__init__.py
+++ b/homeassistant/components/evohome/__init__.py
@@ -29,16 +29,15 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
ACCESS_TOKEN,
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
index c71831fa4bc..64e7367bc32 100644
--- a/homeassistant/components/evohome/climate.py
+++ b/homeassistant/components/evohome/climate.py
@@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
ATTR_DURATION_DAYS,
diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py
index b5842c1073a..a42d8ef7582 100644
--- a/homeassistant/components/evohome/entity.py
+++ b/homeassistant/components/evohome/entity.py
@@ -16,7 +16,7 @@ from evohomeasync2.schema.const import (
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import EvoBroker, EvoService
from .const import DOMAIN
diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py
index f84d2945779..0e2de36eb47 100644
--- a/homeassistant/components/evohome/helpers.py
+++ b/homeassistant/components/evohome/helpers.py
@@ -11,7 +11,7 @@ from typing import Any
import evohomeasync2 as evo
from homeassistant.const import CONF_SCAN_INTERVAL
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json
index 9e88c9bb031..ca032643c9d 100644
--- a/homeassistant/components/evohome/strings.json
+++ b/homeassistant/components/evohome/strings.json
@@ -32,7 +32,7 @@
"fields": {
"entity_id": {
"name": "Entity",
- "description": "The entity_id of the Evohome zone."
+ "description": "The entity ID of the Evohome zone."
},
"setpoint": {
"name": "Setpoint",
@@ -49,8 +49,8 @@
"description": "Sets a zone to follow its schedule.",
"fields": {
"entity_id": {
- "name": "Entity",
- "description": "The entity_id of the zone."
+ "name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]",
+ "description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]"
}
}
}
diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py
index a50e16b5dda..2c3cf9de12d 100644
--- a/homeassistant/components/evohome/water_heater.py
+++ b/homeassistant/components/evohome/water_heater.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER
from .entity import EvoChild
diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py
index 6885304e0de..43a71458fb2 100644
--- a/homeassistant/components/ezviz/__init__.py
+++ b/homeassistant/components/ezviz/__init__.py
@@ -11,7 +11,6 @@ from pyezviz.exceptions import (
PyEzvizError,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -22,12 +21,11 @@ from .const import (
CONF_FFMPEG_ARGUMENTS,
CONF_RFSESSION_ID,
CONF_SESSION_ID,
- DATA_COORDINATOR,
DEFAULT_FFMPEG_ARGUMENTS,
DEFAULT_TIMEOUT,
DOMAIN,
)
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -50,9 +48,8 @@ PLATFORMS_BY_TYPE: dict[str, list] = {
}
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool:
"""Set up EZVIZ from a config entry."""
- hass.data.setdefault(DOMAIN, {})
sensor_type: str = entry.data[CONF_TYPE]
ezviz_client = None
@@ -90,20 +87,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from error
coordinator = EzvizDataUpdateCoordinator(
- hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT]
+ hass, entry, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT]
)
await coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
+ entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
# Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect.
# Cameras are accessed via local RTSP stream with unique credentials per camera.
# Separate camera entities allow for credential changes per camera.
- if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]:
- for item in hass.config_entries.async_entries(domain=DOMAIN):
+ if sensor_type == ATTR_TYPE_CAMERA:
+ for item in hass.config_entries.async_loaded_entries(domain=DOMAIN):
if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
_LOGGER.debug("Reload Ezviz main account with camera entry")
await hass.config_entries.async_reload(item.entry_id)
@@ -116,19 +113,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool:
"""Unload a config entry."""
sensor_type = entry.data[CONF_TYPE]
- unload_ok = await hass.config_entries.async_unload_platforms(
+ return await hass.config_entries.async_unload_platforms(
entry, PLATFORMS_BY_TYPE[sensor_type]
)
- if sensor_type == ATTR_TYPE_CLOUD and unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py
index f30a7852b4e..66a76df2cdc 100644
--- a/homeassistant/components/ezviz/alarm_control_panel.py
+++ b/homeassistant/components/ezviz/alarm_control_panel.py
@@ -15,14 +15,13 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER
-from .coordinator import EzvizDataUpdateCoordinator
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -49,12 +48,12 @@ ALARM_TYPE = EzvizAlarmControlPanelEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Ezviz alarm control panel."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
device_info = DeviceInfo(
identifiers={(DOMAIN, entry.unique_id)}, # type: ignore[arg-type]
diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py
index c13375cb487..6f0d87c8218 100644
--- a/homeassistant/components/ezviz/binary_sensor.py
+++ b/homeassistant/components/ezviz/binary_sensor.py
@@ -7,12 +7,10 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
PARALLEL_UPDATES = 1
@@ -34,12 +32,12 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
[
diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py
index 3c89677da09..b99674b0693 100644
--- a/homeassistant/components/ezviz/button.py
+++ b/homeassistant/components/ezviz/button.py
@@ -11,13 +11,11 @@ from pyezviz.constants import SupportExt
from pyezviz.exceptions import HTTPError, PyEzvizError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
PARALLEL_UPDATES = 1
@@ -68,12 +66,12 @@ BUTTON_ENTITIES = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ button based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
# Add button entities if supportExt value indicates PTZ capbility.
# Could be missing or "0" for unsupported.
diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py
index 3c4a5f70ff4..d96fc949c86 100644
--- a/homeassistant/components/ezviz/camera.py
+++ b/homeassistant/components/ezviz/camera.py
@@ -10,11 +10,7 @@ from homeassistant.components import ffmpeg
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.ffmpeg import get_ffmpeg_manager
from homeassistant.components.stream import CONF_USE_WALLCLOCK_AS_TIMESTAMPS
-from homeassistant.config_entries import (
- SOURCE_IGNORE,
- SOURCE_INTEGRATION_DISCOVERY,
- ConfigEntry,
-)
+from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_INTEGRATION_DISCOVERY
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery_flow
@@ -26,26 +22,25 @@ from homeassistant.helpers.entity_platform import (
from .const import (
ATTR_SERIAL,
CONF_FFMPEG_ARGUMENTS,
- DATA_COORDINATOR,
DEFAULT_CAMERA_USERNAME,
DEFAULT_FFMPEG_ARGUMENTS,
DOMAIN,
SERVICE_WAKE_DEVICE,
)
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ cameras based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
camera_entities = []
diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py
index a7551737c10..845656c1d1d 100644
--- a/homeassistant/components/ezviz/config_flow.py
+++ b/homeassistant/components/ezviz/config_flow.py
@@ -17,12 +17,7 @@ from pyezviz.exceptions import (
from pyezviz.test_cam_rtsp import TestRTSPAuth
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_CUSTOMIZE,
CONF_IP_ADDRESS,
@@ -48,6 +43,7 @@ from .const import (
EU_URL,
RUSSIA_URL,
)
+from .coordinator import EzvizConfigEntry
_LOGGER = logging.getLogger(__name__)
DEFAULT_OPTIONS = {
@@ -148,7 +144,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
- def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler:
+ def async_get_options_flow(
+ config_entry: EzvizConfigEntry,
+ ) -> EzvizOptionsFlowHandler:
"""Get the options flow for this handler."""
return EzvizOptionsFlowHandler()
diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py
index 651110dd5d7..e6de538335c 100644
--- a/homeassistant/components/ezviz/const.py
+++ b/homeassistant/components/ezviz/const.py
@@ -33,6 +33,3 @@ RUSSIA_URL = "apirus.ezvizru.com"
DEFAULT_CAMERA_USERNAME = "admin"
DEFAULT_TIMEOUT = 25
DEFAULT_FFMPEG_ARGUMENTS = ""
-
-# Data
-DATA_COORDINATOR = "coordinator"
diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py
index c983371f4f8..0830784a501 100644
--- a/homeassistant/components/ezviz/coordinator.py
+++ b/homeassistant/components/ezviz/coordinator.py
@@ -13,6 +13,7 @@ from pyezviz.exceptions import (
PyEzvizError,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -21,19 +22,32 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+type EzvizConfigEntry = ConfigEntry[EzvizDataUpdateCoordinator]
+
class EzvizDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching EZVIZ data."""
def __init__(
- self, hass: HomeAssistant, *, api: EzvizClient, api_timeout: int
+ self,
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ *,
+ api: EzvizClient,
+ api_timeout: int,
) -> None:
"""Initialize global EZVIZ data updater."""
self.ezviz_client = api
self._api_timeout = api_timeout
update_interval = timedelta(seconds=30)
- super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=DOMAIN,
+ update_interval=update_interval,
+ )
async def _async_update_data(self) -> dict:
"""Fetch data from EZVIZ."""
diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py
index 73c09244222..d4c7a267b1e 100644
--- a/homeassistant/components/ezviz/image.py
+++ b/homeassistant/components/ezviz/image.py
@@ -8,14 +8,14 @@ from pyezviz.exceptions import PyEzvizError
from pyezviz.utils import decrypt_image
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
-from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry
+from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .const import DOMAIN
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
_LOGGER = logging.getLogger(__name__)
@@ -27,13 +27,13 @@ IMAGE_TYPE = ImageEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ image entities based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizLastMotion(hass, coordinator, camera) for camera in coordinator.data
diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py
index c35b53b47b7..145c8b1ca20 100644
--- a/homeassistant/components/ezviz/light.py
+++ b/homeassistant/components/ezviz/light.py
@@ -8,7 +8,6 @@ from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt
from pyezviz.exceptions import HTTPError, PyEzvizError
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -17,8 +16,7 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage,
)
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
PARALLEL_UPDATES = 1
@@ -26,12 +24,12 @@ BRIGHTNESS_RANGE = (1, 255)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ lights based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizLight(coordinator, camera)
diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py
index 08fbd3afb34..9e8a20f36dd 100644
--- a/homeassistant/components/ezviz/number.py
+++ b/homeassistant/components/ezviz/number.py
@@ -16,14 +16,12 @@ from pyezviz.exceptions import (
)
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizBaseEntity
SCAN_INTERVAL = timedelta(seconds=3600)
@@ -51,12 +49,12 @@ NUMBER_TYPE = EzvizNumberEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizNumber(coordinator, camera, value, entry.entry_id)
diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py
index d6dc3dc8550..8e037fe6c33 100644
--- a/homeassistant/components/ezviz/select.py
+++ b/homeassistant/components/ezviz/select.py
@@ -8,14 +8,12 @@ from pyezviz.constants import DeviceSwitchType, SoundMode
from pyezviz.exceptions import HTTPError, PyEzvizError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
PARALLEL_UPDATES = 1
@@ -38,12 +36,12 @@ SELECT_TYPE = EzvizSelectEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ select entities based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizSelect(coordinator, camera)
diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py
index e0750b985fc..f3d50836bc7 100644
--- a/homeassistant/components/ezviz/sensor.py
+++ b/homeassistant/components/ezviz/sensor.py
@@ -7,13 +7,11 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
PARALLEL_UPDATES = 1
@@ -72,12 +70,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
[
diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py
index 8bacceff29f..5a612aa0772 100644
--- a/homeassistant/components/ezviz/siren.py
+++ b/homeassistant/components/ezviz/siren.py
@@ -13,16 +13,14 @@ from homeassistant.components.siren import (
SirenEntityDescription,
SirenEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import event as evt
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.event as evt
from homeassistant.helpers.restore_state import RestoreEntity
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizBaseEntity
PARALLEL_UPDATES = 1
@@ -35,12 +33,12 @@ SIREN_ENTITY_TYPE = SirenEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizSirenEntity(coordinator, camera, SIREN_ENTITY_TYPE)
diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json
index 58ac9dfde09..f1653661cdd 100644
--- a/homeassistant/components/ezviz/strings.json
+++ b/homeassistant/components/ezviz/strings.json
@@ -29,7 +29,7 @@
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "Enter credentials to reauthenticate to ezviz cloud account",
+ "description": "Enter credentials to reauthenticate to EZVIZ cloud account",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -45,7 +45,7 @@
"abort": {
"already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account",
+ "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
@@ -53,7 +53,7 @@
"step": {
"init": {
"data": {
- "timeout": "Request Timeout (seconds)",
+ "timeout": "Request timeout (seconds)",
"ffmpeg_arguments": "Arguments passed to ffmpeg for cameras"
}
}
@@ -185,22 +185,22 @@
},
"services": {
"set_alarm_detection_sensibility": {
- "name": "Detection sensitivity",
- "description": "Sets the detection sensibility level.",
+ "name": "Set detection sensibility",
+ "description": "Changes the sensibility level of the motion detection.",
"fields": {
"level": {
- "name": "Sensitivity level",
- "description": "Sensibility level (1-6) for type 0 (Normal camera) or (1-100) for type 3 (PIR sensor camera)."
+ "name": "Level",
+ "description": "Sensibility level. 1-6 for type 0 (normal camera), or 1-100 for type 3 (PIR sensor camera)."
},
"type_value": {
- "name": "Detection type",
- "description": "Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera."
+ "name": "Type",
+ "description": "Detection type. 0 for normal camera, or 3 for PIR sensor camera."
}
}
},
"wake_device": {
"name": "Wake camera",
- "description": "This can be used to wake the camera/device from hibernation."
+ "description": "Wakes a camera from sleep mode. Especially useful for battery cameras."
}
}
}
diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py
index 65fb7b9f36b..1a347c931a6 100644
--- a/homeassistant/components/ezviz/switch.py
+++ b/homeassistant/components/ezviz/switch.py
@@ -13,13 +13,11 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -107,12 +105,12 @@ SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ switch based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizSwitch(coordinator, camera, switch_number)
diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py
index 25a506a0052..3027e048688 100644
--- a/homeassistant/components/ezviz/update.py
+++ b/homeassistant/components/ezviz/update.py
@@ -12,13 +12,11 @@ from homeassistant.components.update import (
UpdateEntityDescription,
UpdateEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_COORDINATOR, DOMAIN
-from .coordinator import EzvizDataUpdateCoordinator
+from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
PARALLEL_UPDATES = 1
@@ -30,12 +28,12 @@ UPDATE_ENTITY_TYPES = UpdateEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EzvizConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
- coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
- DATA_COORDINATOR
- ]
+ coordinator = entry.runtime_data
async_add_entities(
EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES)
diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py
index 3319f6bdebd..edd46d24982 100644
--- a/homeassistant/components/facebook/notify.py
+++ b/homeassistant/components/facebook/notify.py
@@ -17,7 +17,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py
index 9e6d23556d2..d71d404c7a0 100644
--- a/homeassistant/components/fail2ban/sensor.py
+++ b/homeassistant/components/fail2ban/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py
index 462983278b0..6be13b23568 100644
--- a/homeassistant/components/familyhub/camera.py
+++ b/homeassistant/components/familyhub/camera.py
@@ -11,8 +11,8 @@ from homeassistant.components.camera import (
)
from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py
index 863ae705603..b9e20e8dc91 100644
--- a/homeassistant/components/fan/__init__.py
+++ b/homeassistant/components/fan/__init__.py
@@ -9,7 +9,7 @@ import logging
import math
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py
index 9faed54c041..31617cb220b 100644
--- a/homeassistant/components/feedreader/__init__.py
+++ b/homeassistant/components/feedreader/__init__.py
@@ -2,15 +2,12 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_URL, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.util.hass_dict import HassKey
-from .const import CONF_MAX_ENTRIES, DOMAIN
-from .coordinator import FeedReaderCoordinator, StoredData
-
-type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator]
+from .const import DOMAIN
+from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData
CONF_URLS = "urls"
@@ -23,12 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -
if not storage.is_initialized:
await storage.async_setup()
- coordinator = FeedReaderCoordinator(
- hass,
- entry.data[CONF_URL],
- entry.options[CONF_MAX_ENTRIES],
- storage,
- )
+ coordinator = FeedReaderCoordinator(hass, entry, storage)
await coordinator.async_setup()
diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py
index f3e56ad1778..3d0fec1a6f5 100644
--- a/homeassistant/components/feedreader/config_flow.py
+++ b/homeassistant/components/feedreader/config_flow.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py
index fc338d63268..9901bd9f1b4 100644
--- a/homeassistant/components/feedreader/coordinator.py
+++ b/homeassistant/components/feedreader/coordinator.py
@@ -13,13 +13,14 @@ from urllib.error import URLError
import feedparser
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER
+from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER
DELAY_SAVE = 30
STORAGE_VERSION = 1
@@ -27,37 +28,39 @@ STORAGE_VERSION = 1
_LOGGER = getLogger(__name__)
+type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator]
+
class FeedReaderCoordinator(
DataUpdateCoordinator[list[feedparser.FeedParserDict] | None]
):
"""Abstraction over Feedparser module."""
- config_entry: ConfigEntry
+ config_entry: FeedReaderConfigEntry
def __init__(
self,
hass: HomeAssistant,
- url: str,
- max_entries: int,
+ config_entry: FeedReaderConfigEntry,
storage: StoredData,
) -> None:
"""Initialize the FeedManager object, poll as per scan interval."""
- super().__init__(
- hass=hass,
- logger=_LOGGER,
- name=f"{DOMAIN} {url}",
- update_interval=DEFAULT_SCAN_INTERVAL,
- )
- self.url = url
+ self.url = config_entry.data[CONF_URL]
self.feed_author: str | None = None
self.feed_version: str | None = None
- self._max_entries = max_entries
+ self._max_entries = config_entry.options[CONF_MAX_ENTRIES]
self._storage = storage
self._last_entry_timestamp: struct_time | None = None
self._event_type = EVENT_FEEDREADER
self._feed: feedparser.FeedParserDict | None = None
- self._feed_id = url
+ self._feed_id = self.url
+ super().__init__(
+ hass=hass,
+ logger=_LOGGER,
+ config_entry=config_entry,
+ name=f"{DOMAIN} {self.url}",
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ )
@callback
def _log_no_entries(self) -> None:
diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py
index 99803e9636c..fc5341b025e 100644
--- a/homeassistant/components/ffmpeg/__init__.py
+++ b/homeassistant/components/ffmpeg/__init__.py
@@ -7,7 +7,7 @@ import re
from haffmpeg.core import HAFFmpeg
from haffmpeg.tools import IMAGE_JPEG, FFVersion, ImageFrame
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.const import (
@@ -17,7 +17,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py
index 2c46c4c29d1..03566ba162c 100644
--- a/homeassistant/components/ffmpeg/camera.py
+++ b/homeassistant/components/ffmpeg/camera.py
@@ -16,8 +16,8 @@ from homeassistant.components.camera import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py
index 7dc32fd96a3..3adae8441df 100644
--- a/homeassistant/components/ffmpeg_motion/binary_sensor.py
+++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.ffmpeg import (
)
from homeassistant.const import CONF_NAME, CONF_REPEAT
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py
index abbf77eba6b..1623d7c7660 100644
--- a/homeassistant/components/ffmpeg_noise/binary_sensor.py
+++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py
@@ -22,7 +22,7 @@ from homeassistant.components.ffmpeg import (
from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py
index bc6e6340111..86e81a596d7 100644
--- a/homeassistant/components/fido/sensor.py
+++ b/homeassistant/components/fido/sensor.py
@@ -28,8 +28,8 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py
index 10e3d4a4ac6..3d61dbb04e0 100644
--- a/homeassistant/components/file/notify.py
+++ b/homeassistant/components/file/notify.py
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON
diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py
index 8350cee91bf..0c2a0277434 100644
--- a/homeassistant/components/filesize/coordinator.py
+++ b/homeassistant/components/filesize/coordinator.py
@@ -9,7 +9,7 @@ import pathlib
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py
index 7f3f6cbfffc..9a4f4913c9f 100644
--- a/homeassistant/components/filter/__init__.py
+++ b/homeassistant/components/filter/__init__.py
@@ -1,6 +1,25 @@
"""The filter component."""
-from homeassistant.const import Platform
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
-DOMAIN = "filter"
-PLATFORMS = [Platform.SENSOR]
+from .const import PLATFORMS
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Filter from a config entry."""
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload Filter config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py
new file mode 100644
index 00000000000..dac2d8995bf
--- /dev/null
+++ b/homeassistant/components/filter/config_flow.py
@@ -0,0 +1,243 @@
+"""Config flow for filter."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, cast
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
+from homeassistant.helpers.schema_config_entry_flow import (
+ SchemaCommonFlowHandler,
+ SchemaConfigFlowHandler,
+ SchemaFlowFormStep,
+)
+from homeassistant.helpers.selector import (
+ DurationSelector,
+ DurationSelectorConfig,
+ EntitySelector,
+ EntitySelectorConfig,
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TextSelector,
+)
+
+from .const import (
+ CONF_FILTER_LOWER_BOUND,
+ CONF_FILTER_NAME,
+ CONF_FILTER_PRECISION,
+ CONF_FILTER_RADIUS,
+ CONF_FILTER_TIME_CONSTANT,
+ CONF_FILTER_UPPER_BOUND,
+ CONF_FILTER_WINDOW_SIZE,
+ CONF_TIME_SMA_TYPE,
+ DEFAULT_FILTER_RADIUS,
+ DEFAULT_FILTER_TIME_CONSTANT,
+ DEFAULT_NAME,
+ DEFAULT_PRECISION,
+ DEFAULT_WINDOW_SIZE,
+ DOMAIN,
+ FILTER_NAME_LOWPASS,
+ FILTER_NAME_OUTLIER,
+ FILTER_NAME_RANGE,
+ FILTER_NAME_THROTTLE,
+ FILTER_NAME_TIME_SMA,
+ FILTER_NAME_TIME_THROTTLE,
+ TIME_SMA_LAST,
+)
+
+FILTERS = [
+ FILTER_NAME_LOWPASS,
+ FILTER_NAME_OUTLIER,
+ FILTER_NAME_RANGE,
+ FILTER_NAME_THROTTLE,
+ FILTER_NAME_TIME_SMA,
+ FILTER_NAME_TIME_THROTTLE,
+]
+
+
+async def get_next_step(user_input: dict[str, Any]) -> str:
+ """Return next step for options."""
+ return cast(str, user_input[CONF_FILTER_NAME])
+
+
+async def validate_options(
+ handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ """Validate options selected."""
+
+ if CONF_FILTER_WINDOW_SIZE in user_input and isinstance(
+ user_input[CONF_FILTER_WINDOW_SIZE], float
+ ):
+ user_input[CONF_FILTER_WINDOW_SIZE] = int(user_input[CONF_FILTER_WINDOW_SIZE])
+ if CONF_FILTER_TIME_CONSTANT in user_input:
+ user_input[CONF_FILTER_TIME_CONSTANT] = int(
+ user_input[CONF_FILTER_TIME_CONSTANT]
+ )
+ if CONF_FILTER_PRECISION in user_input:
+ user_input[CONF_FILTER_PRECISION] = int(user_input[CONF_FILTER_PRECISION])
+
+ handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001
+
+ return user_input
+
+
+DATA_SCHEMA_SETUP = vol.Schema(
+ {
+ vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
+ vol.Required(CONF_ENTITY_ID): EntitySelector(
+ EntitySelectorConfig(domain=[SENSOR_DOMAIN])
+ ),
+ vol.Required(CONF_FILTER_NAME): SelectSelector(
+ SelectSelectorConfig(
+ options=FILTERS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_FILTER_NAME,
+ )
+ ),
+ }
+)
+
+BASE_OPTIONS_SCHEMA = {
+ vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector(
+ NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
+ )
+}
+
+OUTLIER_SCHEMA = vol.Schema(
+ {
+ vol.Optional(
+ CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE
+ ): NumberSelector(
+ NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
+ ),
+ vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): NumberSelector(
+ NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX)
+ ),
+ }
+).extend(BASE_OPTIONS_SCHEMA)
+
+LOWPASS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(
+ CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE
+ ): NumberSelector(
+ NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
+ ),
+ vol.Optional(
+ CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT
+ ): NumberSelector(
+ NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
+ ),
+ }
+).extend(BASE_OPTIONS_SCHEMA)
+
+RANGE_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector(
+ NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX)
+ ),
+ vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector(
+ NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX)
+ ),
+ }
+).extend(BASE_OPTIONS_SCHEMA)
+
+TIME_SMA_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector(
+ SelectSelectorConfig(
+ options=[TIME_SMA_LAST],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_TIME_SMA_TYPE,
+ )
+ ),
+ vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector(
+ DurationSelectorConfig(enable_day=False, allow_negative=False)
+ ),
+ }
+).extend(BASE_OPTIONS_SCHEMA)
+
+THROTTLE_SCHEMA = vol.Schema(
+ {
+ vol.Optional(
+ CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE
+ ): NumberSelector(
+ NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
+ ),
+ }
+).extend(BASE_OPTIONS_SCHEMA)
+
+TIME_THROTTLE_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector(
+ DurationSelectorConfig(enable_day=False, allow_negative=False)
+ ),
+ }
+).extend(BASE_OPTIONS_SCHEMA)
+
+CONFIG_FLOW = {
+ "user": SchemaFlowFormStep(
+ schema=DATA_SCHEMA_SETUP,
+ next_step=get_next_step,
+ ),
+ "lowpass": SchemaFlowFormStep(
+ schema=LOWPASS_SCHEMA, validate_user_input=validate_options
+ ),
+ "outlier": SchemaFlowFormStep(
+ schema=OUTLIER_SCHEMA, validate_user_input=validate_options
+ ),
+ "range": SchemaFlowFormStep(
+ schema=RANGE_SCHEMA, validate_user_input=validate_options
+ ),
+ "time_simple_moving_average": SchemaFlowFormStep(
+ schema=TIME_SMA_SCHEMA, validate_user_input=validate_options
+ ),
+ "throttle": SchemaFlowFormStep(
+ schema=THROTTLE_SCHEMA, validate_user_input=validate_options
+ ),
+ "time_throttle": SchemaFlowFormStep(
+ schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options
+ ),
+}
+OPTIONS_FLOW = {
+ "init": SchemaFlowFormStep(
+ schema=None,
+ next_step=get_next_step,
+ ),
+ "lowpass": SchemaFlowFormStep(
+ schema=LOWPASS_SCHEMA, validate_user_input=validate_options
+ ),
+ "outlier": SchemaFlowFormStep(
+ schema=OUTLIER_SCHEMA, validate_user_input=validate_options
+ ),
+ "range": SchemaFlowFormStep(
+ schema=RANGE_SCHEMA, validate_user_input=validate_options
+ ),
+ "time_simple_moving_average": SchemaFlowFormStep(
+ schema=TIME_SMA_SCHEMA, validate_user_input=validate_options
+ ),
+ "throttle": SchemaFlowFormStep(
+ schema=THROTTLE_SCHEMA, validate_user_input=validate_options
+ ),
+ "time_throttle": SchemaFlowFormStep(
+ schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options
+ ),
+}
+
+
+class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
+ """Handle a config flow for Filter."""
+
+ config_flow = CONFIG_FLOW
+ options_flow = OPTIONS_FLOW
+
+ def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
+ """Return config entry title."""
+ return cast(str, options[CONF_NAME])
diff --git a/homeassistant/components/filter/const.py b/homeassistant/components/filter/const.py
new file mode 100644
index 00000000000..92d2498528e
--- /dev/null
+++ b/homeassistant/components/filter/const.py
@@ -0,0 +1,36 @@
+"""The filter component constants."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "filter"
+PLATFORMS = [Platform.SENSOR]
+
+CONF_INDEX = "index"
+
+FILTER_NAME_RANGE = "range"
+FILTER_NAME_LOWPASS = "lowpass"
+FILTER_NAME_OUTLIER = "outlier"
+FILTER_NAME_THROTTLE = "throttle"
+FILTER_NAME_TIME_THROTTLE = "time_throttle"
+FILTER_NAME_TIME_SMA = "time_simple_moving_average"
+
+CONF_FILTERS = "filters"
+CONF_FILTER_NAME = "filter"
+CONF_FILTER_WINDOW_SIZE = "window_size"
+CONF_FILTER_PRECISION = "precision"
+CONF_FILTER_RADIUS = "radius"
+CONF_FILTER_TIME_CONSTANT = "time_constant"
+CONF_FILTER_LOWER_BOUND = "lower_bound"
+CONF_FILTER_UPPER_BOUND = "upper_bound"
+CONF_TIME_SMA_TYPE = "type"
+
+TIME_SMA_LAST = "last"
+
+WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1
+WINDOW_SIZE_UNIT_TIME = 2
+
+DEFAULT_NAME = "Filtered sensor"
+DEFAULT_WINDOW_SIZE = 1
+DEFAULT_PRECISION = 2
+DEFAULT_FILTER_RADIUS = 2.0
+DEFAULT_FILTER_TIME_CONSTANT = 10
diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json
index 4d9a8992036..392351a235d 100644
--- a/homeassistant/components/filter/manifest.json
+++ b/homeassistant/components/filter/manifest.json
@@ -2,6 +2,7 @@
"domain": "filter",
"name": "Filter",
"codeowners": ["@dgomes"],
+ "config_flow": true,
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/filter",
"integration_type": "helper",
diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py
index 549d74ffd09..330e61f499e 100644
--- a/homeassistant/components/filter/sensor.py
+++ b/homeassistant/components/filter/sensor.py
@@ -24,6 +24,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@@ -42,48 +43,46 @@ from homeassistant.core import (
State,
callback,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
+from homeassistant.util import dt as dt_util
from homeassistant.util.decorator import Registry
-import homeassistant.util.dt as dt_util
-from . import DOMAIN, PLATFORMS
+from .const import (
+ CONF_FILTER_LOWER_BOUND,
+ CONF_FILTER_NAME,
+ CONF_FILTER_PRECISION,
+ CONF_FILTER_RADIUS,
+ CONF_FILTER_TIME_CONSTANT,
+ CONF_FILTER_UPPER_BOUND,
+ CONF_FILTER_WINDOW_SIZE,
+ CONF_FILTERS,
+ CONF_TIME_SMA_TYPE,
+ DEFAULT_FILTER_RADIUS,
+ DEFAULT_FILTER_TIME_CONSTANT,
+ DEFAULT_PRECISION,
+ DEFAULT_WINDOW_SIZE,
+ DOMAIN,
+ FILTER_NAME_LOWPASS,
+ FILTER_NAME_OUTLIER,
+ FILTER_NAME_RANGE,
+ FILTER_NAME_THROTTLE,
+ FILTER_NAME_TIME_SMA,
+ FILTER_NAME_TIME_THROTTLE,
+ PLATFORMS,
+ TIME_SMA_LAST,
+ WINDOW_SIZE_UNIT_NUMBER_EVENTS,
+ WINDOW_SIZE_UNIT_TIME,
+)
_LOGGER = logging.getLogger(__name__)
-FILTER_NAME_RANGE = "range"
-FILTER_NAME_LOWPASS = "lowpass"
-FILTER_NAME_OUTLIER = "outlier"
-FILTER_NAME_THROTTLE = "throttle"
-FILTER_NAME_TIME_THROTTLE = "time_throttle"
-FILTER_NAME_TIME_SMA = "time_simple_moving_average"
FILTERS: Registry[str, type[Filter]] = Registry()
-CONF_FILTERS = "filters"
-CONF_FILTER_NAME = "filter"
-CONF_FILTER_WINDOW_SIZE = "window_size"
-CONF_FILTER_PRECISION = "precision"
-CONF_FILTER_RADIUS = "radius"
-CONF_FILTER_TIME_CONSTANT = "time_constant"
-CONF_FILTER_LOWER_BOUND = "lower_bound"
-CONF_FILTER_UPPER_BOUND = "upper_bound"
-CONF_TIME_SMA_TYPE = "type"
-
-TIME_SMA_LAST = "last"
-
-WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1
-WINDOW_SIZE_UNIT_TIME = 2
-
-DEFAULT_WINDOW_SIZE = 1
-DEFAULT_PRECISION = 2
-DEFAULT_FILTER_RADIUS = 2.0
-DEFAULT_FILTER_TIME_CONSTANT = 10
-
-NAME_TEMPLATE = "{} filter"
ICON = "mdi:chart-line-variant"
FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)})
@@ -199,6 +198,32 @@ async def async_setup_platform(
async_add_entities([SensorFilter(name, unique_id, entity_id, filters)])
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Filter sensor entry."""
+ name: str = entry.options[CONF_NAME]
+ entity_id: str = entry.options[CONF_ENTITY_ID]
+
+ filter_config = {
+ k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID)
+ }
+ if CONF_FILTER_WINDOW_SIZE in filter_config and isinstance(
+ filter_config[CONF_FILTER_WINDOW_SIZE], dict
+ ):
+ filter_config[CONF_FILTER_WINDOW_SIZE] = timedelta(
+ **filter_config[CONF_FILTER_WINDOW_SIZE]
+ )
+
+ filters = [
+ FILTERS[filter_config.pop(CONF_FILTER_NAME)](entity=entity_id, **filter_config)
+ ]
+
+ async_add_entities([SensorFilter(name, entry.entry_id, entity_id, filters)])
+
+
class SensorFilter(SensorEntity):
"""Representation of a Filter Sensor."""
diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json
index 2a83a05bb96..b0403227fd4 100644
--- a/homeassistant/components/filter/strings.json
+++ b/homeassistant/components/filter/strings.json
@@ -1,5 +1,197 @@
{
"title": "Filter",
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ },
+ "step": {
+ "user": {
+ "description": "Add a filter sensor. UI configuration is limited to a single filter, use YAML for filter chain.",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "entity_id": "Entity",
+ "filter": "Filter"
+ },
+ "data_description": {
+ "name": "Name for the created entity.",
+ "entity_id": "Entity to filter from.",
+ "filter": "Select filter to configure."
+ }
+ },
+ "outlier": {
+ "description": "Read the documentation for further details on how to configure the filter sensor using these options.",
+ "data": {
+ "window_size": "Window size",
+ "precision": "Precision",
+ "radius": "Radius"
+ },
+ "data_description": {
+ "window_size": "Size of the window of previous states.",
+ "precision": "Defines the number of decimal places of the calculated sensor value.",
+ "radius": "Band radius from median of previous states."
+ }
+ },
+ "lowpass": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "time_constant": "Time constant"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output."
+ }
+ },
+ "range": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "lower_bound": "Lower bound",
+ "upper_bound": "Upper bound"
+ },
+ "data_description": {
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "lower_bound": "Lower bound for filter range.",
+ "upper_bound": "Upper bound for filter range."
+ }
+ },
+ "time_simple_moving_average": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "type": "Type"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "type": "Defines the type of Simple Moving Average."
+ }
+ },
+ "throttle": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
+ }
+ },
+ "time_throttle": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
+ }
+ }
+ }
+ },
+ "options": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ },
+ "step": {
+ "outlier": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "radius": "[%key:component::filter::config::step::outlier::data::radius%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]"
+ }
+ },
+ "lowpass": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]"
+ }
+ },
+ "range": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]",
+ "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]"
+ },
+ "data_description": {
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]",
+ "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]"
+ }
+ },
+ "time_simple_moving_average": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]",
+ "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
+ "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]"
+ }
+ },
+ "throttle": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
+ }
+ },
+ "time_throttle": {
+ "description": "[%key:component::filter::config::step::outlier::description%]",
+ "data": {
+ "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data::precision%]"
+ },
+ "data_description": {
+ "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
+ "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
+ }
+ }
+ }
+ },
+ "selector": {
+ "filter": {
+ "options": {
+ "range": "Range",
+ "lowpass": "Lowpass",
+ "outlier": "Outlier",
+ "throttle": "Throttle",
+ "time_throttle": "Time throttle",
+ "time_simple_moving_average": "Moving Average (Time based)"
+ }
+ },
+ "type": {
+ "options": {
+ "last": "Last"
+ }
+ }
+ },
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py
index a1cd565153f..318325dbb09 100644
--- a/homeassistant/components/fints/sensor.py
+++ b/homeassistant/components/fints/sensor.py
@@ -9,7 +9,7 @@ from typing import Any
from fints.client import FinTS3PinTanClient
from fints.models import SEPAAccount
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py
index b5ced70b846..d5132627b9d 100644
--- a/homeassistant/components/fivem/config_flow.py
+++ b/homeassistant/components/fivem/config_flow.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json
index fd58922a481..f925a625259 100644
--- a/homeassistant/components/fivem/strings.json
+++ b/homeassistant/components/fivem/strings.json
@@ -14,7 +14,7 @@
},
"error": {
"cannot_connect": "Failed to connect. Please check the host and port and try again. Also ensure that you are running the latest FiveM server.",
- "invalid_game_name": "The api of the game you are trying to connect to is not a FiveM game.",
+ "invalid_game_name": "The API of the game you are trying to connect to is not a FiveM game.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py
index f8b4546d4c7..3fb241208ad 100644
--- a/homeassistant/components/fixer/sensor.py
+++ b/homeassistant/components/fixer/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TARGET
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py
index 008c0765c07..71f6c174dde 100644
--- a/homeassistant/components/fleetgo/device_tracker.py
+++ b/homeassistant/components/fleetgo/device_tracker.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_utc_time_change
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py
index 8be5df4eca7..32c94638b1f 100644
--- a/homeassistant/components/flexit/climate.py
+++ b/homeassistant/components/flexit/climate.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py
index cd160480674..281e960f222 100644
--- a/homeassistant/components/flic/binary_sensor.py
+++ b/homeassistant/components/flic/binary_sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py
index 0edb80004fd..d0dd38bd490 100644
--- a/homeassistant/components/flo/coordinator.py
+++ b/homeassistant/components/flo/coordinator.py
@@ -12,7 +12,7 @@ from orjson import JSONDecodeError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN as FLO_DOMAIN, LOGGER
diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py
index 811ee51749c..f50e04cba36 100644
--- a/homeassistant/components/flock/notify.py
+++ b/homeassistant/components/flock/notify.py
@@ -14,8 +14,8 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json
index 5f3021960b5..acdb8e35fe0 100644
--- a/homeassistant/components/flume/strings.json
+++ b/homeassistant/components/flume/strings.json
@@ -8,7 +8,7 @@
"step": {
"user": {
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token",
- "title": "Connect to your Flume Account",
+ "title": "Connect to your Flume account",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"client_secret": "Client Secret",
@@ -18,7 +18,7 @@
},
"reauth_confirm": {
"description": "The password for {username} is no longer valid.",
- "title": "Reauthenticate your Flume Account",
+ "title": "Reauthenticate your Flume account",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
@@ -65,11 +65,11 @@
"services": {
"list_notifications": {
"name": "List notifications",
- "description": "Return user notifications.",
+ "description": "Returns a list of fetched user notifications.",
"fields": {
"config_entry": {
"name": "Flume",
- "description": "The flume config entry for which to return notifications."
+ "description": "The Flume config entry for which to return notifications."
}
}
}
diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py
index 1472dfa4bf1..7597a7c9c9a 100644
--- a/homeassistant/components/flux_led/__init__.py
+++ b/homeassistant/components/flux_led/__init__.py
@@ -133,7 +133,7 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) ->
and mac_matches_by_one(entity_mac, unique_id)
):
# Old format {dhcp_mac}....., New format {discovery_mac}....
- new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id):]}"
+ new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id) :]}"
else:
return None
_LOGGER.debug(
diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py
index 9a02120f33a..035be5b115c 100644
--- a/homeassistant/components/flux_led/config_flow.py
+++ b/homeassistant/components/flux_led/config_flow.py
@@ -16,7 +16,6 @@ from flux_led.const import (
from flux_led.scanner import FluxLEDDiscovery
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigEntry,
@@ -30,6 +29,7 @@ from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType
from . import async_wifi_bulb_for_host
@@ -78,7 +78,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
return FluxLedOptionsFlow()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
self._discovered_device = FluxLEDDiscovery(
diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py
index ca7fb7aeea2..2a0b5795970 100644
--- a/homeassistant/components/flux_led/light.py
+++ b/homeassistant/components/flux_led/light.py
@@ -25,8 +25,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_EFFECT
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json
index 962098a0bf8..fcb16c9742b 100644
--- a/homeassistant/components/flux_led/manifest.json
+++ b/homeassistant/components/flux_led/manifest.json
@@ -53,5 +53,5 @@
"documentation": "https://www.home-assistant.io/integrations/flux_led",
"iot_class": "local_push",
"loggers": ["flux_led"],
- "requirements": ["flux-led==1.1.0"]
+ "requirements": ["flux-led==1.1.3"]
}
diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json
index aa56708c645..8f4517ff722 100644
--- a/homeassistant/components/flux_led/strings.json
+++ b/homeassistant/components/flux_led/strings.json
@@ -101,7 +101,7 @@
},
"speed_pct": {
"name": "Speed",
- "description": "Effect speed for the custom effect (0-100)."
+ "description": "The speed of the effect in % (0-100, default 50)."
},
"transition": {
"name": "Transition",
diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py
index 3a8a4fdc380..4667a6c348d 100644
--- a/homeassistant/components/folder/sensor.py
+++ b/homeassistant/components/folder/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfInformation
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py
index 5fb9f08f1c0..b2b2d498f60 100644
--- a/homeassistant/components/forked_daapd/config_flow.py
+++ b/homeassistant/components/forked_daapd/config_flow.py
@@ -7,7 +7,6 @@ from typing import Any
from pyforked_daapd import ForkedDaapdAPI
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -17,6 +16,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_LIBRESPOT_JAVA_PORT,
@@ -164,7 +164,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a discovered forked-daapd device."""
version_num = 0
diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py
index af2bc92a065..4360dd031c7 100644
--- a/homeassistant/components/fortios/device_tracker.py
+++ b/homeassistant/components/fortios/device_tracker.py
@@ -19,7 +19,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py
index b4d64464972..09df989447a 100644
--- a/homeassistant/components/foscam/__init__.py
+++ b/homeassistant/components/foscam/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
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 .config_flow import DEFAULT_RTSP_PORT
from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
@@ -36,6 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ # Migrate to correct unique IDs for switches
+ await async_migrate_entities(hass, entry)
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -92,3 +95,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.debug("Migration to version %s successful", entry.version)
return True
+
+
+async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Migrate old entry."""
+
+ @callback
+ def _update_unique_id(
+ entity_entry: RegistryEntry,
+ ) -> dict[str, str] | None:
+ """Update unique ID of entity entry."""
+ if (
+ entity_entry.domain == Platform.SWITCH
+ and entity_entry.unique_id == "sleep_switch"
+ ):
+ entity_new_unique_id = f"{entity_entry.config_entry_id}_sleep_switch"
+ return {"new_unique_id": entity_new_unique_id}
+
+ return None
+
+ # Migrate entities
+ await async_migrate_entries(hass, entry.entry_id, _update_unique_id)
diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py
index 9eae211881f..dfc51aaa064 100644
--- a/homeassistant/components/foscam/switch.py
+++ b/homeassistant/components/foscam/switch.py
@@ -41,7 +41,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity):
"""Initialize a Foscam Sleep Switch."""
super().__init__(coordinator, config_entry.entry_id)
- self._attr_unique_id = "sleep_switch"
+ self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch"
self._attr_translation_key = "sleep_switch"
self._attr_has_entity_name = True
diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py
index 12a29fd632e..25effac073d 100644
--- a/homeassistant/components/foursquare/__init__.py
+++ b/homeassistant/components/foursquare/__init__.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py
index 90c8ef3246e..c7e3071c771 100644
--- a/homeassistant/components/free_mobile/notify.py
+++ b/homeassistant/components/free_mobile/notify.py
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py
index 88e2165defd..62a1cd14b3d 100644
--- a/homeassistant/components/freebox/config_flow.py
+++ b/homeassistant/components/freebox/config_flow.py
@@ -6,9 +6,9 @@ from typing import Any
from freebox_api.exceptions import AuthorizationError, HttpRequestError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .router import get_api, get_hosts_list_if_supported
@@ -99,7 +99,7 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="link", errors=errors)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Initialize flow from zeroconf."""
zeroconf_properties = discovery_info.properties
diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py
index 097c8c138ee..588992a7f21 100644
--- a/homeassistant/components/freebox/sensor.py
+++ b/homeassistant/components/freebox/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .entity import FreeboxHomeEntity
diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py
index 5338c0d0700..460ad163f61 100644
--- a/homeassistant/components/freedns/__init__.py
+++ b/homeassistant/components/freedns/__init__.py
@@ -9,8 +9,8 @@ import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
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.helpers.typing import ConfigType
diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py
index 1e1830ca1c1..05a2a07707f 100644
--- a/homeassistant/components/fritz/__init__.py
+++ b/homeassistant/components/fritz/__init__.py
@@ -2,7 +2,6 @@
import logging
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -12,26 +11,36 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from .const import (
- DATA_FRITZ,
DEFAULT_SSL,
DOMAIN,
FRITZ_AUTH_EXCEPTIONS,
FRITZ_EXCEPTIONS,
PLATFORMS,
)
-from .coordinator import AvmWrapper, FritzData
-from .services import async_setup_services, async_unload_services
+from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
+from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up fritzboxtools integration."""
+ await async_setup_services(hass)
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool:
"""Set up fritzboxtools from config entry."""
_LOGGER.debug("Setting up FRITZ!Box Tools component")
avm_wrapper = AvmWrapper(
hass=hass,
+ config_entry=entry,
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
username=entry.data[CONF_USERNAME],
@@ -54,42 +63,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await avm_wrapper.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = avm_wrapper
+ entry.runtime_data = avm_wrapper
- if DATA_FRITZ not in hass.data:
- hass.data[DATA_FRITZ] = FritzData()
+ if FRITZ_DATA_KEY not in hass.data:
+ hass.data[FRITZ_DATA_KEY] = FritzData()
entry.async_on_unload(entry.add_update_listener(update_listener))
# Load the other platforms like switch
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- await async_setup_services(hass)
-
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool:
"""Unload FRITZ!Box Tools config entry."""
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
- fritz_data = hass.data[DATA_FRITZ]
+ fritz_data = hass.data[FRITZ_DATA_KEY]
fritz_data.tracked.pop(avm_wrapper.unique_id)
if not bool(fritz_data.tracked):
- hass.data.pop(DATA_FRITZ)
+ hass.data.pop(FRITZ_DATA_KEY)
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- await async_unload_services(hass)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None:
"""Update when config_entry options update."""
if entry.options:
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py
index cb1f698bdca..7553328a64c 100644
--- a/homeassistant/components/fritz/binary_sensor.py
+++ b/homeassistant/components/fritz/binary_sensor.py
@@ -11,13 +11,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-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
-from .coordinator import AvmWrapper, ConnectionInfo
+from .coordinator import ConnectionInfo, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
@@ -51,11 +49,13 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FritzConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up FRITZ!Box binary sensors")
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
connection_info = await avm_wrapper.async_get_connection_info()
diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py
index 263521d23f4..f3ffbe42099 100644
--- a/homeassistant/components/fritz/button.py
+++ b/homeassistant/components/fritz/button.py
@@ -12,15 +12,21 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DATA_FRITZ, DOMAIN, MeshRoles
-from .coordinator import AvmWrapper, FritzData, FritzDevice, _is_tracked
+from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles
+from .coordinator import (
+ FRITZ_DATA_KEY,
+ AvmWrapper,
+ FritzConfigEntry,
+ FritzData,
+ FritzDevice,
+ _is_tracked,
+)
from .entity import FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
@@ -65,12 +71,12 @@ BUTTONS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FritzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set buttons for device."""
_LOGGER.debug("Setting up buttons")
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
entities_list: list[ButtonEntity] = [
FritzButton(avm_wrapper, entry.title, button) for button in BUTTONS
@@ -80,7 +86,7 @@ async def async_setup_entry(
async_add_entities(entities_list)
return
- data_fritz: FritzData = hass.data[DATA_FRITZ]
+ data_fritz = hass.data[FRITZ_DATA_KEY]
entities_list += _async_wol_buttons_list(avm_wrapper, data_fritz)
async_add_entities(entities_list)
diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py
index 920ecda1c52..fb17f872cb6 100644
--- a/homeassistant/components/fritz/config_flow.py
+++ b/homeassistant/components/fritz/config_flow.py
@@ -13,17 +13,11 @@ from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzConnectionException
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.components.device_tracker import (
CONF_CONSIDER_HOME,
DEFAULT_CONSIDER_HOME,
)
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -32,6 +26,12 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -48,6 +48,7 @@ from .const import (
ERROR_UPNP_NOT_CONFIGURED,
FRITZ_AUTH_EXCEPTIONS,
)
+from .coordinator import FritzConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -62,7 +63,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: FritzConfigEntry,
) -> FritzBoxToolsOptionsFlowHandler:
"""Get the options flow for this handler."""
return FritzBoxToolsOptionsFlowHandler()
@@ -111,7 +112,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
return None
- async def async_check_configured_entry(self) -> ConfigEntry | None:
+ async def async_check_configured_entry(self) -> FritzConfigEntry | None:
"""Check if entry is configured."""
current_host = await self.hass.async_add_executor_job(
socket.gethostbyname, self._host
@@ -150,7 +151,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
return DEFAULT_HTTPS_PORT if user_input[CONF_SSL] else DEFAULT_HTTP_PORT
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "")
@@ -160,14 +161,13 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
self._host = host
self._name = (
- discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
- or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
+ discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
+ or discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
)
uuid: str | None
- if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
- if uuid.startswith("uuid:"):
- uuid = uuid[5:]
+ if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN):
+ uuid = uuid.removeprefix("uuid:")
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured({CONF_HOST: self._host})
diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py
index 9a266507c25..2237823bc3b 100644
--- a/homeassistant/components/fritz/const.py
+++ b/homeassistant/components/fritz/const.py
@@ -40,8 +40,6 @@ PLATFORMS = [
CONF_OLD_DISCOVERY = "old_discovery"
DEFAULT_CONF_OLD_DISCOVERY = False
-DATA_FRITZ = "fritz_data"
-
DSL_CONNECTION: Literal["dsl"] = "dsl"
DEFAULT_DEVICE_NAME = "Unknown device"
@@ -56,9 +54,6 @@ ERROR_CANNOT_CONNECT = "cannot_connect"
ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured"
ERROR_UNKNOWN = "unknown_error"
-FRITZ_SERVICES = "fritz_services"
-SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password"
-
SWITCH_TYPE_DEFLECTION = "CallDeflection"
SWITCH_TYPE_PORTFORWARD = "PortForward"
SWITCH_TYPE_PROFILE = "Profile"
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 272295cd512..38d76c92871 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -16,11 +16,10 @@ from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
FritzSecurityError,
- FritzServiceError,
)
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
-from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN
+from fritzconnection.lib.fritzwlan import FritzGuestWLAN
import xmltodict
from homeassistant.components.device_tracker import (
@@ -29,7 +28,7 @@ from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
@@ -37,6 +36,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
+from homeassistant.util.hass_dict import HassKey
from .const import (
CONF_OLD_DISCOVERY,
@@ -46,14 +46,17 @@ from .const import (
DEFAULT_USERNAME,
DOMAIN,
FRITZ_EXCEPTIONS,
- SERVICE_SET_GUEST_WIFI_PW,
MeshRoles,
)
_LOGGER = logging.getLogger(__name__)
+FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN)
-def _is_tracked(mac: str, current_devices: ValuesView) -> bool:
+type FritzConfigEntry = ConfigEntry[AvmWrapper]
+
+
+def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
"""Check if device is already tracked."""
return any(mac in tracked for tracked in current_devices)
@@ -61,7 +64,7 @@ def _is_tracked(mac: str, current_devices: ValuesView) -> bool:
def device_filter_out_from_trackers(
mac: str,
device: FritzDevice,
- current_devices: ValuesView,
+ current_devices: ValuesView[set[str]],
) -> bool:
"""Check if device should be filtered out from trackers."""
reason: str | None = None
@@ -162,11 +165,12 @@ class UpdateCoordinatorDataType(TypedDict):
class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"""FritzBoxTools class."""
- config_entry: ConfigEntry
+ config_entry: FritzConfigEntry
def __init__(
self,
hass: HomeAssistant,
+ config_entry: FritzConfigEntry,
password: str,
port: int,
username: str = DEFAULT_USERNAME,
@@ -176,6 +180,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"""Initialize FritzboxTools class."""
super().__init__(
hass=hass,
+ config_entry=config_entry,
logger=_LOGGER,
name=f"{DOMAIN}-{host}-coordinator",
update_interval=timedelta(seconds=30),
@@ -441,7 +446,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
hosts_info = await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_info
)
- except Exception as ex: # noqa: BLE001
+ except Exception as ex:
if not self.hass.is_stopping:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -693,34 +698,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device.id, remove_config_entry_id=config_entry.entry_id
)
- async def service_fritzbox(
- self, service_call: ServiceCall, config_entry: ConfigEntry
- ) -> None:
- """Define FRITZ!Box services."""
- _LOGGER.debug("FRITZ!Box service: %s", service_call.service)
-
- if not self.connection:
- raise HomeAssistantError(
- translation_domain=DOMAIN, translation_key="unable_to_connect"
- )
-
- try:
- if service_call.service == SERVICE_SET_GUEST_WIFI_PW:
- await self.async_trigger_set_guest_password(
- service_call.data.get("password"),
- service_call.data.get("length", DEFAULT_PASSWORD_LENGTH),
- )
- return
-
- except (FritzServiceError, FritzActionError) as ex:
- raise HomeAssistantError(
- translation_domain=DOMAIN, translation_key="service_parameter_unknown"
- ) from ex
- except FritzConnectionException as ex:
- raise HomeAssistantError(
- translation_domain=DOMAIN, translation_key="service_not_supported"
- ) from ex
-
class AvmWrapper(FritzBoxTools):
"""Setup AVM wrapper for API calls."""
@@ -899,9 +876,9 @@ class AvmWrapper(FritzBoxTools):
class FritzData:
"""Storage class for platform global data."""
- tracked: dict = field(default_factory=dict)
- profile_switches: dict = field(default_factory=dict)
- wol_buttons: dict = field(default_factory=dict)
+ tracked: dict[str, set[str]] = field(default_factory=dict)
+ profile_switches: dict[str, set[str]] = field(default_factory=dict)
+ wol_buttons: dict[str, set[str]] = field(default_factory=dict)
class FritzDevice:
diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py
index d1270a0510c..ba3c9a5aab6 100644
--- a/homeassistant/components/fritz/device_tracker.py
+++ b/homeassistant/components/fritz/device_tracker.py
@@ -6,14 +6,14 @@ import datetime
import logging
from homeassistant.components.device_tracker import ScannerEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_FRITZ, DOMAIN
from .coordinator import (
+ FRITZ_DATA_KEY,
AvmWrapper,
+ FritzConfigEntry,
FritzData,
FritzDevice,
device_filter_out_from_trackers,
@@ -24,12 +24,14 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FritzConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up device tracker for FRITZ!Box component."""
_LOGGER.debug("Starting FRITZ!Box device tracker")
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
- data_fritz: FritzData = hass.data[DATA_FRITZ]
+ avm_wrapper = entry.runtime_data
+ data_fritz = hass.data[FRITZ_DATA_KEY]
@callback
def update_avm_device() -> None:
diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py
index 8823d55baa9..b9ae9edf04d 100644
--- a/homeassistant/components/fritz/diagnostics.py
+++ b/homeassistant/components/fritz/diagnostics.py
@@ -5,21 +5,19 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import AvmWrapper
+from .coordinator import FritzConfigEntry
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: FritzConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py
index 19c98446ccd..d305551b097 100644
--- a/homeassistant/components/fritz/image.py
+++ b/homeassistant/components/fritz/image.py
@@ -8,14 +8,12 @@ import logging
from requests.exceptions import RequestException
from homeassistant.components.image import ImageEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
-from .const import DOMAIN
-from .coordinator import AvmWrapper
+from .coordinator import AvmWrapper, FritzConfigEntry
from .entity import FritzBoxBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -23,11 +21,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FritzConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up guest WiFi QR code for device."""
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
guest_wifi_info = await hass.async_add_executor_job(
avm_wrapper.fritz_guest_wifi.get_info
diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml
index 06c572f93a6..805705eb4b4 100644
--- a/homeassistant/components/fritz/quality_scale.yaml
+++ b/homeassistant/components/fritz/quality_scale.yaml
@@ -1,8 +1,6 @@
rules:
# Bronze
- action-setup:
- status: todo
- comment: still in async_setup_entry, needs to be moved to async_setup
+ action-setup: done
appropriate-polling: done
brands: done
common-modules: done
@@ -24,9 +22,7 @@ rules:
has-entity-name:
status: todo
comment: partially done
- runtime-data:
- status: todo
- comment: still uses hass.data
+ runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py
index 11ee0ad5510..81b50bd21ac 100644
--- a/homeassistant/components/fritz/sensor.py
+++ b/homeassistant/components/fritz/sensor.py
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
EntityCategory,
@@ -27,8 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
-from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION
-from .coordinator import AvmWrapper, ConnectionInfo
+from .const import DSL_CONNECTION, UPTIME_DEVIATION
+from .coordinator import ConnectionInfo, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
@@ -267,11 +266,13 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FritzConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up FRITZ!Box sensors")
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
connection_info = await avm_wrapper.async_get_connection_info()
diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py
index bace7480ba5..02e6c91f4bf 100644
--- a/homeassistant/components/fritz/services.py
+++ b/homeassistant/components/fritz/services.py
@@ -1,21 +1,25 @@
"""Services for Fritz integration."""
-from __future__ import annotations
-
import logging
+from fritzconnection.core.exceptions import (
+ FritzActionError,
+ FritzConnectionException,
+ FritzServiceError,
+)
+from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.service import async_extract_config_entry_ids
-from .const import DOMAIN, FRITZ_SERVICES, SERVICE_SET_GUEST_WIFI_PW
-from .coordinator import AvmWrapper
+from .const import DOMAIN
+from .coordinator import FritzConfigEntry
_LOGGER = logging.getLogger(__name__)
+SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password"
SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
{
vol.Required("device_id"): str,
@@ -24,71 +28,48 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
}
)
-SERVICE_LIST: list[tuple[str, vol.Schema | None]] = [
- (SERVICE_SET_GUEST_WIFI_PW, SERVICE_SCHEMA_SET_GUEST_WIFI_PW),
-]
+
+async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
+ """Call Fritz set guest wifi password service."""
+ hass = service_call.hass
+ target_entry_ids = await async_extract_config_entry_ids(hass, service_call)
+ target_entries: list[FritzConfigEntry] = [
+ loaded_entry
+ for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN)
+ if loaded_entry.entry_id in target_entry_ids
+ ]
+
+ if not target_entries:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_found",
+ translation_placeholders={"service": service_call.service},
+ )
+
+ for target_entry in target_entries:
+ _LOGGER.debug("Executing service %s", service_call.service)
+ avm_wrapper = target_entry.runtime_data
+ try:
+ await avm_wrapper.async_trigger_set_guest_password(
+ service_call.data.get("password"),
+ service_call.data.get("length", DEFAULT_PASSWORD_LENGTH),
+ )
+ except (FritzServiceError, FritzActionError) as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="service_parameter_unknown"
+ ) from ex
+ except FritzConnectionException as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="service_not_supported"
+ ) from ex
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Fritz integration."""
- for service, _ in SERVICE_LIST:
- if hass.services.has_service(DOMAIN, service):
- return
-
- async def async_call_fritz_service(service_call: ServiceCall) -> None:
- """Call correct Fritz service."""
-
- if not (
- fritzbox_entry_ids := await _async_get_configured_avm_device(
- hass, service_call
- )
- ):
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="config_entry_not_found",
- translation_placeholders={"service": service_call.service},
- )
-
- for entry_id in fritzbox_entry_ids:
- _LOGGER.debug("Executing service %s", service_call.service)
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry_id]
- if config_entry := hass.config_entries.async_get_entry(entry_id):
- await avm_wrapper.service_fritzbox(service_call, config_entry)
- else:
- _LOGGER.error(
- "Executing service %s failed, no config entry found",
- service_call.service,
- )
-
- for service, schema in SERVICE_LIST:
- hass.services.async_register(DOMAIN, service, async_call_fritz_service, schema)
-
-
-async def _async_get_configured_avm_device(
- hass: HomeAssistant, service_call: ServiceCall
-) -> list:
- """Get FritzBoxTools class from config entry."""
-
- list_entry_id: list = []
- for entry_id in await async_extract_config_entry_ids(hass, service_call):
- config_entry = hass.config_entries.async_get_entry(entry_id)
- if (
- config_entry
- and config_entry.domain == DOMAIN
- and config_entry.state == ConfigEntryState.LOADED
- ):
- list_entry_id.append(entry_id)
- return list_entry_id
-
-
-async def async_unload_services(hass: HomeAssistant) -> None:
- """Unload services for Fritz integration."""
-
- if not hass.data.get(FRITZ_SERVICES):
- return
-
- hass.data[FRITZ_SERVICES] = False
-
- for service, _ in SERVICE_LIST:
- hass.services.async_remove(DOMAIN, service)
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_GUEST_WIFI_PW,
+ _async_set_guest_wifi_password,
+ SERVICE_SCHEMA_SET_GUEST_WIFI_PW,
+ )
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index 372af89cc9e..9c12fe0cecc 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -7,7 +7,6 @@ from typing import Any
from homeassistant.components.network import async_get_source_ip
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -18,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import (
- DATA_FRITZ,
DOMAIN,
SWITCH_TYPE_DEFLECTION,
SWITCH_TYPE_PORTFORWARD,
@@ -28,7 +26,9 @@ from .const import (
MeshRoles,
)
from .coordinator import (
+ FRITZ_DATA_KEY,
AvmWrapper,
+ FritzConfigEntry,
FritzData,
FritzDevice,
SwitchInfo,
@@ -220,12 +220,14 @@ async def async_all_entities_list(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FritzConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up switches")
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
- data_fritz: FritzData = hass.data[DATA_FRITZ]
+ avm_wrapper = entry.runtime_data
+ data_fritz = hass.data[FRITZ_DATA_KEY]
_LOGGER.debug("Fritzbox services: %s", avm_wrapper.connection.services)
diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py
index 6969f201f27..ad23a076ca6 100644
--- a/homeassistant/components/fritz/update.py
+++ b/homeassistant/components/fritz/update.py
@@ -11,13 +11,11 @@ from homeassistant.components.update import (
UpdateEntityDescription,
UpdateEntityFeature,
)
-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
-from .coordinator import AvmWrapper
+from .coordinator import AvmWrapper, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
@@ -29,11 +27,13 @@ class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescripti
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FritzConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AVM FRITZ!Box update entities."""
_LOGGER.debug("Setting up AVM FRITZ!Box update entities")
- avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
+ avm_wrapper = entry.runtime_data
entities = [FritzBoxUpdateEntity(avm_wrapper, entry.title)]
diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py
index 07bc8fb15f2..afe6f1abba8 100644
--- a/homeassistant/components/fritzbox/__init__.py
+++ b/homeassistant/components/fritzbox/__init__.py
@@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) ->
await async_migrate_entries(hass, entry.entry_id, _update_unique_id)
- coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id)
+ coordinator = FritzboxDataUpdateCoordinator(hass, entry)
await coordinator.async_setup()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py
index d5a81fdef1a..87a87ac691f 100644
--- a/homeassistant/components/fritzbox/climate.py
+++ b/homeassistant/components/fritzbox/climate.py
@@ -141,7 +141,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
await self.async_set_hvac_mode(hvac_mode)
elif target_temp is not None:
await self.hass.async_add_executor_job(
- self.data.set_target_temperature, target_temp
+ self.data.set_target_temperature, target_temp, True
)
else:
return
diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py
index ffec4a9ea29..3f66b43cc0c 100644
--- a/homeassistant/components/fritzbox/config_flow.py
+++ b/homeassistant/components/fritzbox/config_flow.py
@@ -11,9 +11,13 @@ from pyfritzhome import Fritzhome, LoginError
from requests.exceptions import HTTPError
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN
@@ -109,7 +113,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
host = urlparse(discovery_info.ssdp_location).hostname
@@ -121,9 +125,8 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
):
return self.async_abort(reason="ignore_ip6_link_local")
- if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
- if uuid.startswith("uuid:"):
- uuid = uuid[5:]
+ if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN):
+ uuid = uuid.removeprefix("uuid:")
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured({CONF_HOST: host})
@@ -138,7 +141,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass.config_entries.async_update_entry(entry, unique_id=uuid)
return self.async_abort(reason="already_configured")
- self._name = str(discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or host)
+ self._name = str(discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or host)
self.context["title_placeholders"] = {"name": self._name}
return await self.async_step_confirm()
diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py
index a6a30ffdc6a..34df3885deb 100644
--- a/homeassistant/components/fritzbox/coordinator.py
+++ b/homeassistant/components/fritzbox/coordinator.py
@@ -38,12 +38,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
fritz: Fritzhome
has_templates: bool
- def __init__(self, hass: HomeAssistant, name: str) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
"""Initialize the Fritzbox Smarthome device coordinator."""
super().__init__(
hass,
LOGGER,
- name=name,
+ config_entry=config_entry,
+ name=config_entry.entry_id,
update_interval=timedelta(seconds=30),
)
diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py
index de87d6f8852..070bb868298 100644
--- a/homeassistant/components/fritzbox/cover.py
+++ b/homeassistant/components/fritzbox/cover.py
@@ -71,21 +71,21 @@ class FritzboxCover(FritzBoxDeviceEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self.hass.async_add_executor_job(self.data.set_blind_open)
+ await self.hass.async_add_executor_job(self.data.set_blind_open, True)
await self.coordinator.async_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self.hass.async_add_executor_job(self.data.set_blind_close)
+ await self.hass.async_add_executor_job(self.data.set_blind_close, True)
await self.coordinator.async_refresh()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
await self.hass.async_add_executor_job(
- self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION]
+ self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION], True
)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
- await self.hass.async_add_executor_job(self.data.set_blind_stop)
+ await self.hass.async_add_executor_job(self.data.set_blind_stop, True)
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py
index 36cb7dc8cff..94d7d320704 100644
--- a/homeassistant/components/fritzbox/light.py
+++ b/homeassistant/components/fritzbox/light.py
@@ -4,8 +4,6 @@ from __future__ import annotations
from typing import Any, cast
-from requests.exceptions import HTTPError
-
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -122,26 +120,24 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
"""Turn the light on."""
if kwargs.get(ATTR_BRIGHTNESS) is not None:
level = kwargs[ATTR_BRIGHTNESS]
- await self.hass.async_add_executor_job(self.data.set_level, level)
+ await self.hass.async_add_executor_job(self.data.set_level, level, True)
if kwargs.get(ATTR_HS_COLOR) is not None:
- # Try setunmappedcolor first. This allows free color selection,
- # but we don't know if its supported by all devices.
- try:
- # HA gives 0..360 for hue, fritz light only supports 0..359
- unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360)
- unmapped_saturation = round(
- cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0
- )
+ # HA gives 0..360 for hue, fritz light only supports 0..359
+ unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360)
+ unmapped_saturation = round(
+ cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0
+ )
+ if self.data.fullcolorsupport:
+ LOGGER.debug("device has fullcolorsupport, using 'setunmappedcolor'")
await self.hass.async_add_executor_job(
- self.data.set_unmapped_color, (unmapped_hue, unmapped_saturation)
+ self.data.set_unmapped_color,
+ (unmapped_hue, unmapped_saturation),
+ 0,
+ True,
)
- # This will raise 400 BAD REQUEST if the setunmappedcolor is not available
- except HTTPError as err:
- if err.response.status_code != 400:
- raise
+ else:
LOGGER.debug(
- "fritzbox does not support method 'setunmappedcolor', fallback to"
- " 'setcolor'"
+ "device has no fullcolorsupport, using supported colors with 'setcolor'"
)
# find supported hs values closest to what user selected
hue = min(
@@ -152,18 +148,18 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
key=lambda x: abs(x - unmapped_saturation),
)
await self.hass.async_add_executor_job(
- self.data.set_color, (hue, saturation)
+ self.data.set_color, (hue, saturation), 0, True
)
if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
await self.hass.async_add_executor_job(
- self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN]
+ self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN], 0, True
)
- await self.hass.async_add_executor_job(self.data.set_state_on)
+ await self.hass.async_add_executor_job(self.data.set_state_on, True)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
- await self.hass.async_add_executor_job(self.data.set_state_off)
+ await self.hass.async_add_executor_job(self.data.set_state_off, True)
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
index 1a127597b81..2fbb75443b2 100644
--- a/homeassistant/components/fritzbox/manifest.json
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
- "requirements": ["pyfritzhome==0.6.12"],
+ "requirements": ["pyfritzhome==0.6.14"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"
diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py
index 18b676d449e..d83793c77dc 100644
--- a/homeassistant/components/fritzbox/switch.py
+++ b/homeassistant/components/fritzbox/switch.py
@@ -51,13 +51,13 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
self.check_lock_state()
- await self.hass.async_add_executor_job(self.data.set_switch_state_on)
+ await self.hass.async_add_executor_job(self.data.set_switch_state_on, True)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
self.check_lock_state()
- await self.hass.async_add_executor_job(self.data.set_switch_state_off)
+ await self.hass.async_add_executor_job(self.data.set_switch_state_off, True)
await self.coordinator.async_refresh()
def check_lock_state(self) -> None:
diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py
index ccc15d80401..f35c9ce5bc1 100644
--- a/homeassistant/components/fronius/config_flow.py
+++ b/homeassistant/components/fronius/config_flow.py
@@ -9,12 +9,12 @@ from typing import Any, Final
from pyfronius import Fronius, FroniusError
import voluptuous as vol
-from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN, FroniusConfigEntryData
diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py
index 03f666ffafd..c6c3ff4b602 100644
--- a/homeassistant/components/fronius/sensor.py
+++ b/homeassistant/components/fronius/sensor.py
@@ -794,7 +794,7 @@ class LoggerSensor(_FroniusSensorEntity):
"unit"
)
self._attr_unique_id = (
- f'{logger_data["unique_identifier"]["value"]}-{description.key}'
+ f"{logger_data['unique_identifier']['value']}-{description.key}"
)
@@ -815,7 +815,7 @@ class MeterSensor(_FroniusSensorEntity):
if (meter_uid := meter_data["serial"]["value"]) == "n.a.":
meter_uid = (
f"{coordinator.solar_net.solar_net_device_id}:"
- f'{meter_data["model"]["value"]}'
+ f"{meter_data['model']['value']}"
)
self._attr_device_info = DeviceInfo(
@@ -849,7 +849,7 @@ class OhmpilotSensor(_FroniusSensorEntity):
sw_version=device_data["software"]["value"],
via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id),
)
- self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}'
+ self._attr_unique_id = f"{device_data['serial']['value']}-{description.key}"
class PowerFlowSensor(_FroniusSensorEntity):
@@ -883,7 +883,7 @@ class StorageSensor(_FroniusSensorEntity):
super().__init__(coordinator, description, solar_net_id)
storage_data = self._device_data()
- self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}'
+ self._attr_unique_id = f"{storage_data['serial']['value']}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, storage_data["serial"]["value"])},
manufacturer=storage_data["manufacturer"]["value"],
diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py
index c1098ac19d3..6184d888004 100644
--- a/homeassistant/components/frontend/__init__.py
+++ b/homeassistant/components/frontend/__init__.py
@@ -11,7 +11,7 @@ from typing import Any, TypedDict
from aiohttp import hdrs, web, web_urldispatcher
import jinja2
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from yarl import URL
@@ -26,8 +26,7 @@ from homeassistant.const import (
EVENT_THEMES_UPDATED,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import service
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.icon import async_get_icons
from homeassistant.helpers.json import json_dumps_sorted
from homeassistant.helpers.storage import Store
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 2724569d1ed..d27785dcea5 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20250109.2"]
+ "requirements": ["home-assistant-frontend==20250205.0"]
}
diff --git a/homeassistant/components/frontier_silicon/browse_media.py b/homeassistant/components/frontier_silicon/browse_media.py
index 0b51cb767c7..9bad880a9b3 100644
--- a/homeassistant/components/frontier_silicon/browse_media.py
+++ b/homeassistant/components/frontier_silicon/browse_media.py
@@ -38,7 +38,9 @@ def _item_preset_payload(preset: Preset, player_mode: str) -> BrowseMedia:
media_content_type=MediaType.CHANNEL,
# We add 1 to the preset key to keep it in sync with the numbering shown
# on the interface of the device
- media_content_id=f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key)+1}",
+ media_content_id=(
+ f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key) + 1}"
+ ),
can_play=True,
can_expand=False,
)
diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py
index 0612419fc33..f6514da28ff 100644
--- a/homeassistant/components/frontier_silicon/config_flow.py
+++ b/homeassistant/components/frontier_silicon/config_flow.py
@@ -15,9 +15,9 @@ from afsapi import (
)
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
CONF_WEBFSAPI_URL,
@@ -87,7 +87,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Process entity discovered via SSDP."""
diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py
index f25e01bcd11..547545e4feb 100644
--- a/homeassistant/components/fujitsu_fglair/__init__.py
+++ b/homeassistant/components/fujitsu_fglair/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.helpers import aiohttp_client
from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU
from .coordinator import FGLairCoordinator
-PLATFORMS: list[Platform] = [Platform.CLIMATE]
+PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
type FGLairConfigEntry = ConfigEntry[FGLairCoordinator]
diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py
index 5359075c728..c0f5ab7dce4 100644
--- a/homeassistant/components/fujitsu_fglair/climate.py
+++ b/homeassistant/components/fujitsu_fglair/climate.py
@@ -25,13 +25,11 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FGLairConfigEntry
-from .const import DOMAIN
from .coordinator import FGLairCoordinator
+from .entity import FGLairEntity
HA_TO_FUJI_FAN = {
FAN_LOW: FanSpeed.LOW,
@@ -72,28 +70,19 @@ async def async_setup_entry(
)
-class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity):
+class FGLairDevice(FGLairEntity, ClimateEntity):
"""Represent a Fujitsu HVAC device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_precision = PRECISION_HALVES
_attr_target_temperature_step = 0.5
- _attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
"""Store the representation of the device and set the static attributes."""
- super().__init__(coordinator, context=device.device_serial_number)
+ super().__init__(coordinator, device)
self._attr_unique_id = device.device_serial_number
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, device.device_serial_number)},
- name=device.device_name,
- manufacturer="Fujitsu",
- model=device.property_values["model_name"],
- serial_number=device.device_serial_number,
- sw_version=device.property_values["mcu_firmware_version"],
- )
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -109,11 +98,6 @@ class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity):
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._set_attr()
- @property
- def device(self) -> FujitsuHVAC:
- """Return the device object from the coordinator data."""
- return self.coordinator.data[self.coordinator_context]
-
@property
def available(self) -> bool:
"""Return if the device is available."""
diff --git a/homeassistant/components/fujitsu_fglair/coordinator.py b/homeassistant/components/fujitsu_fglair/coordinator.py
index eac3cfd6ce5..d98464e4751 100644
--- a/homeassistant/components/fujitsu_fglair/coordinator.py
+++ b/homeassistant/components/fujitsu_fglair/coordinator.py
@@ -48,10 +48,16 @@ class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]):
raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e
if not listening_entities:
- devices = [dev for dev in devices if isinstance(dev, FujitsuHVAC)]
+ devices = [
+ dev
+ for dev in devices
+ if isinstance(dev, FujitsuHVAC) and dev.is_online()
+ ]
else:
devices = [
- dev for dev in devices if dev.device_serial_number in listening_entities
+ dev
+ for dev in devices
+ if dev.device_serial_number in listening_entities and dev.is_online()
]
try:
diff --git a/homeassistant/components/fujitsu_fglair/entity.py b/homeassistant/components/fujitsu_fglair/entity.py
new file mode 100644
index 00000000000..5c41a8ab18e
--- /dev/null
+++ b/homeassistant/components/fujitsu_fglair/entity.py
@@ -0,0 +1,38 @@
+"""Fujitsu FGlair base entity."""
+
+from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import FGLairCoordinator
+
+
+class FGLairEntity(CoordinatorEntity[FGLairCoordinator]):
+ """Generic Fglair entity (base class)."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
+ """Store the representation of the device."""
+ super().__init__(coordinator, context=device.device_serial_number)
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.device_serial_number)},
+ name=device.device_name,
+ manufacturer="Fujitsu",
+ model=device.property_values["model_name"],
+ serial_number=device.device_serial_number,
+ sw_version=device.property_values["mcu_firmware_version"],
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return super().available and self.coordinator_context in self.coordinator.data
+
+ @property
+ def device(self) -> FujitsuHVAC:
+ """Return the device object from the coordinator data."""
+ return self.coordinator.data[self.coordinator_context]
diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json
index ea08a2cfe02..330685f89fc 100644
--- a/homeassistant/components/fujitsu_fglair/manifest.json
+++ b/homeassistant/components/fujitsu_fglair/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"iot_class": "cloud_polling",
- "requirements": ["ayla-iot-unofficial==1.4.4"]
+ "requirements": ["ayla-iot-unofficial==1.4.5"]
}
diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py
new file mode 100644
index 00000000000..1426e2349ea
--- /dev/null
+++ b/homeassistant/components/fujitsu_fglair/sensor.py
@@ -0,0 +1,47 @@
+"""Outside temperature sensor for Fujitsu FGlair HVAC systems."""
+
+from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .climate import FGLairConfigEntry
+from .coordinator import FGLairCoordinator
+from .entity import FGLairEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: FGLairConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up one Fujitsu HVAC device."""
+ async_add_entities(
+ FGLairOutsideTemperature(entry.runtime_data, device)
+ for device in entry.runtime_data.data.values()
+ )
+
+
+class FGLairOutsideTemperature(FGLairEntity, SensorEntity):
+ """Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump."""
+
+ _attr_device_class = SensorDeviceClass.TEMPERATURE
+ _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_translation_key = "fglair_outside_temp"
+
+ def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
+ """Store the representation of the device."""
+ super().__init__(coordinator, device)
+ self._attr_unique_id = f"{device.device_serial_number}_outside_temperature"
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the sensed outdoor temperature un celsius."""
+ return self.device.outdoor_temperature # type: ignore[no-any-return]
diff --git a/homeassistant/components/fujitsu_fglair/strings.json b/homeassistant/components/fujitsu_fglair/strings.json
index 3ad4e59ec1c..ea97ca416e5 100644
--- a/homeassistant/components/fujitsu_fglair/strings.json
+++ b/homeassistant/components/fujitsu_fglair/strings.json
@@ -35,5 +35,12 @@
"cn": "China"
}
}
+ },
+ "entity": {
+ "sensor": {
+ "fglair_outside_temp": {
+ "name": "Outside temperature"
+ }
+ }
}
}
diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py
index 15771d12b5d..53185e8ab76 100644
--- a/homeassistant/components/fully_kiosk/config_flow.py
+++ b/homeassistant/components/fully_kiosk/config_flow.py
@@ -11,7 +11,6 @@ from fullykiosk import FullyKiosk
from fullykiosk.exceptions import FullyKioskError
import voluptuous as vol
-from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
@@ -22,6 +21,7 @@ from homeassistant.const import (
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from .const import DEFAULT_PORT, DOMAIN, LOGGER
diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py
index 00318a77ab5..e1a4240c9e9 100644
--- a/homeassistant/components/fully_kiosk/image.py
+++ b/homeassistant/components/fully_kiosk/image.py
@@ -12,7 +12,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py
index 089ae1d4246..ac6faf76a9d 100644
--- a/homeassistant/components/fully_kiosk/services.py
+++ b/homeassistant/components/fully_kiosk/services.py
@@ -8,8 +8,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import (
ATTR_APPLICATION,
diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py
index d1ad6f42083..e9dcfd7a151 100644
--- a/homeassistant/components/futurenow/light.py
+++ b/homeassistant/components/futurenow/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py
index 1969ebfffe9..ab4a74c627a 100644
--- a/homeassistant/components/fyta/__init__.py
+++ b/homeassistant/components/fyta/__init__.py
@@ -24,6 +24,8 @@ from .coordinator import FytaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.IMAGE,
Platform.SENSOR,
]
type FytaConfigEntry = ConfigEntry[FytaCoordinator]
diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py
new file mode 100644
index 00000000000..bcef609d01a
--- /dev/null
+++ b/homeassistant/components/fyta/binary_sensor.py
@@ -0,0 +1,117 @@
+"""Binary sensors for Fyta."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Final
+
+from fyta_cli.fyta_models import Plant
+
+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 . import FytaConfigEntry
+from .entity import FytaPlantEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class FytaBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes Fyta binary sensor entity."""
+
+ value_fn: Callable[[Plant], bool]
+
+
+BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [
+ FytaBinarySensorEntityDescription(
+ key="low_battery",
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda plant: plant.low_battery,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="notification_light",
+ translation_key="notification_light",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda plant: plant.notification_light,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="notification_nutrition",
+ translation_key="notification_nutrition",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda plant: plant.notification_nutrition,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="notification_temperature",
+ translation_key="notification_temperature",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda plant: plant.notification_temperature,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="notification_water",
+ translation_key="notification_water",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda plant: plant.notification_water,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="sensor_update_available",
+ device_class=BinarySensorDeviceClass.UPDATE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda plant: plant.sensor_update_available,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="productive_plant",
+ translation_key="productive_plant",
+ value_fn=lambda plant: plant.productive_plant,
+ ),
+ FytaBinarySensorEntityDescription(
+ key="repotted",
+ translation_key="repotted",
+ value_fn=lambda plant: plant.repotted,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the FYTA binary sensors."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ FytaPlantBinarySensor(coordinator, entry, sensor, plant_id)
+ for plant_id in coordinator.fyta.plant_list
+ for sensor in BINARY_SENSORS
+ if sensor.key in dir(coordinator.data.get(plant_id))
+ )
+
+ def _async_add_new_device(plant_id: int) -> None:
+ async_add_entities(
+ FytaPlantBinarySensor(coordinator, entry, sensor, plant_id)
+ for sensor in BINARY_SENSORS
+ if sensor.key in dir(coordinator.data.get(plant_id))
+ )
+
+ coordinator.new_device_callbacks.append(_async_add_new_device)
+
+
+class FytaPlantBinarySensor(FytaPlantEntity, BinarySensorEntity):
+ """Represents a Fyta binary sensor."""
+
+ entity_description: FytaBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return value of the binary sensor."""
+
+ return self.entity_description.value_fn(self.plant)
diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py
index 553960bdcc6..a0c42d449d5 100644
--- a/homeassistant/components/fyta/coordinator.py
+++ b/homeassistant/components/fyta/coordinator.py
@@ -19,7 +19,7 @@ from fyta_cli.fyta_models import Plant
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_EXPIRATION, DOMAIN
diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py
index 4c078098ec1..0d0ec533c44 100644
--- a/homeassistant/components/fyta/entity.py
+++ b/homeassistant/components/fyta/entity.py
@@ -2,8 +2,8 @@
from fyta_cli.fyta_models import Plant
-from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FytaConfigEntry
@@ -20,7 +20,7 @@ class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]):
self,
coordinator: FytaCoordinator,
entry: FytaConfigEntry,
- description: SensorEntityDescription,
+ description: EntityDescription,
plant_id: int,
) -> None:
"""Initialize the Fyta sensor."""
diff --git a/homeassistant/components/fyta/icons.json b/homeassistant/components/fyta/icons.json
index b96eeb15e62..5b6380196f4 100644
--- a/homeassistant/components/fyta/icons.json
+++ b/homeassistant/components/fyta/icons.json
@@ -1,5 +1,25 @@
{
"entity": {
+ "binary_sensor": {
+ "notification_light": {
+ "default": "mdi:lightbulb-alert-outline"
+ },
+ "notification_nutrition": {
+ "default": "mdi:beaker-alert-outline"
+ },
+ "notification_temperature": {
+ "default": "mdi:thermometer-alert"
+ },
+ "notification_water": {
+ "default": "mdi:watering-can-outline"
+ },
+ "productive_plant": {
+ "default": "mdi:fruit-grapes"
+ },
+ "repotted": {
+ "default": "mdi:shovel"
+ }
+ },
"sensor": {
"status": {
"default": "mdi:flower"
@@ -13,6 +33,9 @@
"moisture_status": {
"default": "mdi:water-percent-alert"
},
+ "nutrients_status": {
+ "default": "mdi:emoticon-poop"
+ },
"salinity_status": {
"default": "mdi:sprout-outline"
},
@@ -21,6 +44,12 @@
},
"salinity": {
"default": "mdi:sprout-outline"
+ },
+ "last_fertilised": {
+ "default": "mdi:calendar-check"
+ },
+ "next_fertilisation": {
+ "default": "mdi:calendar-end"
}
}
}
diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py
new file mode 100644
index 00000000000..f03df969dcc
--- /dev/null
+++ b/homeassistant/components/fyta/image.py
@@ -0,0 +1,64 @@
+"""Entity for Fyta plant image."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from homeassistant.components.image import ImageEntity, ImageEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import FytaConfigEntry
+from .coordinator import FytaCoordinator
+from .entity import FytaPlantEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the FYTA plant images."""
+ coordinator = entry.runtime_data
+
+ description = ImageEntityDescription(key="plant_image")
+
+ async_add_entities(
+ FytaPlantImageEntity(coordinator, entry, description, plant_id)
+ for plant_id in coordinator.fyta.plant_list
+ if plant_id in coordinator.data
+ )
+
+ def _async_add_new_device(plant_id: int) -> None:
+ async_add_entities(
+ [FytaPlantImageEntity(coordinator, entry, description, plant_id)]
+ )
+
+ coordinator.new_device_callbacks.append(_async_add_new_device)
+
+
+class FytaPlantImageEntity(FytaPlantEntity, ImageEntity):
+ """Represents a Fyta image."""
+
+ entity_description: ImageEntityDescription
+
+ def __init__(
+ self,
+ coordinator: FytaCoordinator,
+ entry: ConfigEntry,
+ description: ImageEntityDescription,
+ plant_id: int,
+ ) -> None:
+ """Initiatlize Fyta Image entity."""
+ super().__init__(coordinator, entry, description, plant_id)
+ ImageEntity.__init__(self, coordinator.hass)
+
+ self._attr_name = None
+
+ @property
+ def image_url(self) -> str:
+ """Return the image_url for this sensor."""
+ image = self.plant.plant_origin_path
+ if image != self._attr_image_url:
+ self._attr_image_last_updated = datetime.now()
+
+ return image
diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py
index 89ee22265cf..254e4522819 100644
--- a/homeassistant/components/fyta/sensor.py
+++ b/homeassistant/components/fyta/sensor.py
@@ -82,6 +82,13 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [
options=PLANT_MEASUREMENT_STATUS_LIST,
value_fn=lambda plant: plant.moisture_status.name.lower(),
),
+ FytaSensorEntityDescription(
+ key="nutrients_status",
+ translation_key="nutrients_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=PLANT_MEASUREMENT_STATUS_LIST,
+ value_fn=lambda plant: plant.nutrients_status.name.lower(),
+ ),
FytaSensorEntityDescription(
key="salinity_status",
translation_key="salinity_status",
@@ -124,6 +131,18 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda plant: plant.ph,
),
+ FytaSensorEntityDescription(
+ key="fertilise_last",
+ translation_key="last_fertilised",
+ device_class=SensorDeviceClass.DATE,
+ value_fn=lambda plant: plant.fertilise_last,
+ ),
+ FytaSensorEntityDescription(
+ key="fertilise_next",
+ translation_key="next_fertilisation",
+ device_class=SensorDeviceClass.DATE,
+ value_fn=lambda plant: plant.fertilise_next,
+ ),
FytaSensorEntityDescription(
key="battery_level",
native_unit_of_measurement=PERCENTAGE,
diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json
index fc9f424d5aa..1a25f654e19 100644
--- a/homeassistant/components/fyta/strings.json
+++ b/homeassistant/components/fyta/strings.json
@@ -38,6 +38,29 @@
}
},
"entity": {
+ "binary_sensor": {
+ "notification_light": {
+ "name": "Light notification"
+ },
+ "notification_nutrition": {
+ "name": "Nutrition notification"
+ },
+ "notification_temperature": {
+ "name": "Temperature notification"
+ },
+ "notification_water": {
+ "name": "Water notification"
+ },
+ "productive_plant": {
+ "name": "Productive plant"
+ },
+ "repotted": {
+ "name": "Repotted"
+ },
+ "sensor_update_available": {
+ "name": "Sensor update available"
+ }
+ },
"sensor": {
"scientific_name": {
"name": "Scientific name"
@@ -84,6 +107,17 @@
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
+ "nutrients_status": {
+ "name": "Nutrients state",
+ "state": {
+ "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
+ "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
+ "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
+ "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
+ "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
+ "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
+ }
+ },
"salinity_status": {
"name": "Salinity state",
"state": {
@@ -100,6 +134,12 @@
},
"salinity": {
"name": "Salinity"
+ },
+ "last_fertilised": {
+ "name": "Last fertilized"
+ },
+ "next_fertilisation": {
+ "name": "Next fertilization"
}
}
},
diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py
index 82045e91321..ef11038aee4 100644
--- a/homeassistant/components/garadget/cover.py
+++ b/homeassistant/components/garadget/cover.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import track_utc_time_change
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py
index 7aae629974c..47034e61fb9 100644
--- a/homeassistant/components/gardena_bluetooth/__init__.py
+++ b/homeassistant/components/gardena_bluetooth/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceInfo
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator
diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py
index ee8a2663218..c07d2ba6866 100644
--- a/homeassistant/components/gardena_bluetooth/sensor.py
+++ b/homeassistant/components/gardena_bluetooth/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import GardenaBluetoothConfigEntry
from .coordinator import GardenaBluetoothCoordinator
diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py
index 57c8e92499f..a43741b9249 100644
--- a/homeassistant/components/gc100/__init__.py
+++ b/homeassistant/components/gc100/__init__.py
@@ -5,7 +5,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
CONF_PORTS = "ports"
diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py
index 55df72cc3b9..cef798935cb 100644
--- a/homeassistant/components/gc100/binary_sensor.py
+++ b/homeassistant/components/gc100/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py
index 1bcdc7365cf..23b178cc647 100644
--- a/homeassistant/components/gc100/switch.py
+++ b/homeassistant/components/gc100/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py
index 4b0717815c5..b20793fe060 100644
--- a/homeassistant/components/generic/config_flow.py
+++ b/homeassistant/components/generic/config_flow.py
@@ -171,6 +171,8 @@ async def async_test_still(
"""Verify that the still image is valid before we create an entity."""
fmt = None
if not (url := info.get(CONF_STILL_IMAGE_URL)):
+ # If user didn't specify a still image URL,the automatically generated
+ # still image that stream generates is always jpeg.
return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg")
try:
if not isinstance(url, template_helper.Template):
@@ -255,10 +257,6 @@ async def async_test_and_preview_stream(
"""
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
return None
- # Import from stream.worker as stream cannot reexport from worker
- # without forcing the av dependency on default_config
- # pylint: disable-next=import-outside-toplevel
- from homeassistant.components.stream.worker import StreamWorkerError
if not isinstance(stream_source, template_helper.Template):
stream_source = template_helper.Template(stream_source, hass)
@@ -294,8 +292,6 @@ async def async_test_and_preview_stream(
f"{DOMAIN}.test_stream",
)
hls_provider = stream.add_provider(HLS_PROVIDER)
- except StreamWorkerError as err:
- raise InvalidStreamException("unknown_with_details", str(err)) from err
except PermissionError as err:
raise InvalidStreamException("stream_not_permitted") from err
except OSError as err:
@@ -315,8 +311,8 @@ async def async_test_and_preview_stream(
return stream
-def register_preview(hass: HomeAssistant) -> None:
- """Set up previews for camera feeds during config flow."""
+def register_still_preview(hass: HomeAssistant) -> None:
+ """Set up still image preview for camera feeds during config flow."""
hass.data.setdefault(DOMAIN, {})
if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE):
@@ -332,7 +328,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize Generic ConfigFlow."""
- self.preview_cam: dict[str, Any] = {}
+ self.preview_image_settings: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}
self.title = ""
@@ -372,15 +368,10 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
name = (
slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME
)
- if still_url is None:
- # If user didn't specify a still image URL,
- # The automatically generated still image that stream generates
- # is always jpeg
- user_input[CONF_CONTENT_TYPE] = "image/jpeg"
self.user_input = user_input
self.title = name
# temporary preview for user to check the image
- self.preview_cam = user_input
+ self.preview_image_settings = user_input
return await self.async_step_user_confirm()
elif self.user_input:
user_input = self.user_input
@@ -405,7 +396,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=self.title, data={}, options=self.user_input
)
- register_preview(self.hass)
+ register_still_preview(self.hass)
return self.async_show_form(
step_id="user_confirm",
data_schema=vol.Schema(
@@ -428,7 +419,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
def __init__(self) -> None:
"""Initialize Generic IP Camera options flow."""
- self.preview_cam: dict[str, Any] = {}
+ self.preview_image_settings: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}
@@ -455,13 +446,6 @@ class GenericOptionsFlowHandler(OptionsFlow):
errors[CONF_STREAM_SOURCE] = str(err)
self.preview_stream = None
if not errors:
- user_input[CONF_CONTENT_TYPE] = still_format
- still_url = user_input.get(CONF_STILL_IMAGE_URL)
- if still_url is None:
- # If user didn't specify a still image URL,
- # The automatically generated still image that stream generates
- # is always jpeg
- still_format = "image/jpeg"
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
@@ -472,7 +456,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
}
self.user_input = data
# temporary preview for user to check the image
- self.preview_cam = data
+ self.preview_image_settings = data
return await self.async_step_user_confirm()
elif self.user_input:
user_input = self.user_input
@@ -500,7 +484,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
title=self.config_entry.title,
data=self.user_input,
)
- register_preview(self.hass)
+ register_still_preview(self.hass)
return self.async_show_form(
step_id="user_confirm",
data_schema=vol.Schema(
@@ -542,7 +526,7 @@ class CameraImagePreview(HomeAssistantView):
if not flow:
_LOGGER.warning("Unknown flow while getting image preview")
raise web.HTTPNotFound
- user_input = flow.preview_cam
+ user_input = flow.preview_image_settings
camera = GenericCamera(self.hass, user_input, flow_id, "preview")
if not camera.is_on:
_LOGGER.debug("Camera is off")
@@ -583,7 +567,7 @@ async def ws_start_preview(
GenericOptionsFlowHandler,
hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
)
- user_input = flow.preview_cam
+ user_input = flow.preview_image_settings
# Create an EntityPlatform, needed for name translations
platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN)
diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json
index 0b6f07e8205..35c5ae93b72 100644
--- a/homeassistant/components/generic/manifest.json
+++ b/homeassistant/components/generic/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["av==13.1.0", "Pillow==11.0.0"]
+ "requirements": ["av==13.1.0", "Pillow==11.1.0"]
}
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index dd6829eacce..fe6f0253f48 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -268,6 +268,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
else:
self._attr_preset_modes = [PRESET_NONE]
self._presets = presets
+ self._presets_inv = {v: k for k, v in presets.items()}
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added."""
@@ -421,6 +422,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
+ self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
self._target_temp = temperature
await self._async_control_heating(force=True)
self.async_write_ha_state()
diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py
index b106f9907bb..18f50593dca 100644
--- a/homeassistant/components/geniushub/config_flow.py
+++ b/homeassistant/components/geniushub/config_flow.py
@@ -78,7 +78,7 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_host"
except (TimeoutError, aiohttp.ClientConnectionError):
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
@@ -113,7 +113,7 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_host"
except (TimeoutError, aiohttp.ClientConnectionError):
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py
index 7c40c41bda5..24917ab5e95 100644
--- a/homeassistant/components/geniushub/entity.py
+++ b/homeassistant/components/geniushub/entity.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE
diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py
index cfe4107428c..a558ad18672 100644
--- a/homeassistant/components/geniushub/sensor.py
+++ b/homeassistant/components/geniushub/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import GeniusHubConfigEntry
from .entity import GeniusDevice, GeniusEntity
diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json
index faf5011d752..42d53c7fa00 100644
--- a/homeassistant/components/geniushub/strings.json
+++ b/homeassistant/components/geniushub/strings.json
@@ -37,11 +37,11 @@
"services": {
"set_zone_mode": {
"name": "Set zone mode",
- "description": "Set the zone to an operating mode.",
+ "description": "Sets the zone to an operating mode.",
"fields": {
"entity_id": {
"name": "Entity",
- "description": "The zone's entity_id."
+ "description": "The zone's entity ID."
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
@@ -51,7 +51,7 @@
},
"set_zone_override": {
"name": "Set zone override",
- "description": "Overrides the zone's set point for a given duration.",
+ "description": "Overrides the zone's setpoint for a given duration.",
"fields": {
"entity_id": {
"name": "Entity",
diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py
index 877471f002a..06b0320c805 100644
--- a/homeassistant/components/geo_location/__init__.py
+++ b/homeassistant/components/geo_location/__init__.py
@@ -6,7 +6,7 @@ from datetime import timedelta
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py
index 96244e08d1b..5f0d6e92ee1 100644
--- a/homeassistant/components/geo_location/trigger.py
+++ b/homeassistant/components/geo_location/trigger.py
@@ -83,13 +83,8 @@ async def async_attach_trigger(
)
to_match = condition.zone(hass, zone_state, to_state) if to_state else False
- if (
- trigger_event == EVENT_ENTER
- and not from_match
- and to_match
- or trigger_event == EVENT_LEAVE
- and from_match
- and not to_match
+ if (trigger_event == EVENT_ENTER and not from_match and to_match) or (
+ trigger_event == EVENT_LEAVE and from_match and not to_match
):
hass.async_run_hass_job(
job,
diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py
index 0dc8918b7dd..079a47a6c27 100644
--- a/homeassistant/components/geo_rss_events/sensor.py
+++ b/homeassistant/components/geo_rss_events/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
UnitOfLength,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py
index d38514fc412..46a3482ce1e 100644
--- a/homeassistant/components/geofency/__init__.py
+++ b/homeassistant/components/geofency/__init__.py
@@ -16,8 +16,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import slugify
diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py
index 9977f9d84cc..17338119b9f 100644
--- a/homeassistant/components/github/config_flow.py
+++ b/homeassistant/components/github/config_flow.py
@@ -23,11 +23,11 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
-import homeassistant.helpers.config_validation as cv
from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER
diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py
index 6ed3112b2af..933ba0e482e 100644
--- a/homeassistant/components/gitlab_ci/sensor.py
+++ b/homeassistant/components/gitlab_ci/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py
index bc444655908..957ac4e9d8c 100644
--- a/homeassistant/components/gitter/sensor.py
+++ b/homeassistant/components/gitter/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py
index 59eba69d60a..0741926296e 100644
--- a/homeassistant/components/glances/sensor.py
+++ b/homeassistant/components/glances/sensor.py
@@ -375,6 +375,8 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
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()
+ or (
+ isinstance(self._attr_native_value, str)
+ and self._attr_native_value.isnumeric()
+ )
)
diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py
index 3c1c84c42b5..234411936cb 100644
--- a/homeassistant/components/go2rtc/const.py
+++ b/homeassistant/components/go2rtc/const.py
@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
-RECOMMENDED_VERSION = "1.9.7"
+RECOMMENDED_VERSION = "1.9.8"
diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py
index dabe642b658..9764d36e42c 100644
--- a/homeassistant/components/goalzero/config_flow.py
+++ b/homeassistant/components/goalzero/config_flow.py
@@ -8,11 +8,11 @@ from typing import Any
from goalzero import Yeti, exceptions
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER
@@ -27,7 +27,7 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN):
_discovered_ip: str
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py
index 837c0454719..0348d0b428c 100644
--- a/homeassistant/components/gogogate2/config_flow.py
+++ b/homeassistant/components/gogogate2/config_flow.py
@@ -10,7 +10,6 @@ from ismartgate.common import AbstractInfoResponse, ApiError
from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode
import voluptuous as vol
-from homeassistant.components import dhcp, zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_DEVICE,
@@ -19,6 +18,11 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo,
+)
from .common import get_api
from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN
@@ -40,16 +44,14 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN):
self._device_type: str | None = None
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle homekit discovery."""
- await self.async_set_unique_id(
- discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID]
- )
+ await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID])
return await self._async_discovery_handler(discovery_info.host)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
await self.async_set_unique_id(discovery_info.macaddress)
diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py
index 03912c9a1ec..5a88ac612da 100644
--- a/homeassistant/components/goodwe/sensor.py
+++ b/homeassistant/components/goodwe/sensor.py
@@ -37,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER
from .coordinator import GoodweUpdateCoordinator
diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py
index 2ad400aabab..2b7aeadc0ba 100644
--- a/homeassistant/components/google/__init__.py
+++ b/homeassistant/components/google/__init__.py
@@ -28,9 +28,8 @@ from homeassistant.exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
)
-from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import generate_entity_id
from .api import ApiAuthImpl, get_feature_access
diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json
index 85c4714432b..bd04597e513 100644
--- a/homeassistant/components/google/manifest.json
+++ b/homeassistant/components/google/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
- "requirements": ["gcal-sync==6.2.0", "oauth2client==4.1.3", "ical==8.2.0"]
+ "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"]
}
diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json
index acc69c3799a..5ee0cdd9c14 100644
--- a/homeassistant/components/google/strings.json
+++ b/homeassistant/components/google/strings.json
@@ -13,20 +13,22 @@
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
- "code_expired": "Authentication code expired or credential setup is invalid, please try again.",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
- "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console",
- "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
- "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "code_expired": "Authentication code expired or credential setup is invalid, please try again.",
+ "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py
index 76869487ee3..4309a99c0ca 100644
--- a/homeassistant/components/google_assistant/helpers.py
+++ b/homeassistant/components/google_assistant/helpers.py
@@ -521,7 +521,7 @@ def supported_traits_for_state(state: State) -> list[type[trait._Trait]]:
class GoogleEntity:
"""Adaptation of Entity expressed in Google's terms."""
- __slots__ = ("hass", "config", "state", "entity_id", "_traits")
+ __slots__ = ("_traits", "config", "entity_id", "hass", "state")
def __init__(
self, hass: HomeAssistant, config: AbstractConfig, state: State
diff --git a/homeassistant/components/google_assistant/strings.json b/homeassistant/components/google_assistant/strings.json
index 70fac8db6c1..d67600fece1 100644
--- a/homeassistant/components/google_assistant/strings.json
+++ b/homeassistant/components/google_assistant/strings.json
@@ -13,7 +13,7 @@
"fields": {
"agent_user_id": {
"name": "Agent user ID",
- "description": "Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you use this action through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing."
+ "description": "Only needed for automations. Specific Home Assistant user ID (not username, ID in Settings > People > Users > under username) to sync with Google Assistant. Not needed when you use this action through Home Assistant frontend or API. Used in automation, script or other place where context.user_id is missing."
}
}
}
diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json
index 4fd817aadce..87c93023900 100644
--- a/homeassistant/components/google_assistant_sdk/strings.json
+++ b/homeassistant/components/google_assistant_sdk/strings.json
@@ -4,27 +4,27 @@
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
- "auth": {
- "title": "Link Google Account"
- },
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Google Assistant SDK integration needs to re-authenticate your account"
+ },
+ "auth": {
+ "title": "Link Google Account"
}
},
"abort": {
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
- "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
- "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
- "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
- "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py
index f1adc42b4cd..f71ccea00cc 100644
--- a/homeassistant/components/google_cloud/helpers.py
+++ b/homeassistant/components/google_cloud/helpers.py
@@ -12,7 +12,7 @@ from google.oauth2.service_account import Credentials
import voluptuous as vol
from homeassistant.components.tts import CONF_LANG
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py
index 99b7dadbb0e..ebca586d1a3 100644
--- a/homeassistant/components/google_cloud/stt.py
+++ b/homeassistant/components/google_cloud/stt.py
@@ -114,9 +114,9 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
)
)
- async def request_generator() -> (
- AsyncGenerator[speech_v1.StreamingRecognizeRequest]
- ):
+ async def request_generator() -> AsyncGenerator[
+ speech_v1.StreamingRecognizeRequest
+ ]:
# The first request must only contain a streaming_config
yield speech_v1.StreamingRecognizeRequest(streaming_config=streaming_config)
# All subsequent requests must only contain audio_content
diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py
new file mode 100644
index 00000000000..af93956931a
--- /dev/null
+++ b/homeassistant/components/google_drive/__init__.py
@@ -0,0 +1,65 @@
+"""The Google Drive integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from google_drive_api.exceptions import GoogleDriveApiError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import instance_id
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ OAuth2Session,
+ async_get_config_entry_implementation,
+)
+from homeassistant.util.hass_dict import HassKey
+
+from .api import AsyncConfigEntryAuth, DriveClient
+from .const import DOMAIN
+
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}.backup_agent_listeners"
+)
+
+
+type GoogleDriveConfigEntry = ConfigEntry[DriveClient]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool:
+ """Set up Google Drive from a config entry."""
+ auth = AsyncConfigEntryAuth(
+ async_get_clientsession(hass),
+ OAuth2Session(
+ hass, entry, await async_get_config_entry_implementation(hass, entry)
+ ),
+ )
+
+ # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
+ await auth.async_get_access_token()
+
+ client = DriveClient(await instance_id.async_get(hass), auth)
+ entry.runtime_data = client
+
+ # Test we can access Google Drive and raise if not
+ try:
+ await client.async_create_ha_root_folder_if_not_exists()
+ except GoogleDriveApiError as err:
+ raise ConfigEntryNotReady from err
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: GoogleDriveConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ hass.loop.call_soon(_notify_backup_listeners, hass)
+ return True
+
+
+def _notify_backup_listeners(hass: HomeAssistant) -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py
new file mode 100644
index 00000000000..475eddb6231
--- /dev/null
+++ b/homeassistant/components/google_drive/api.py
@@ -0,0 +1,201 @@
+"""API for Google Drive bound to Home Assistant OAuth."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+import json
+import logging
+from typing import Any
+
+from aiohttp import ClientSession, ClientTimeout, StreamReader
+from aiohttp.client_exceptions import ClientError, ClientResponseError
+from google_drive_api.api import AbstractAuth, GoogleDriveApi
+
+from homeassistant.components.backup import AgentBackup, suggested_filename
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryNotReady,
+ HomeAssistantError,
+)
+from homeassistant.helpers import config_entry_oauth2_flow
+
+_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AsyncConfigEntryAuth(AbstractAuth):
+ """Provide Google Drive authentication tied to an OAuth2 based config entry."""
+
+ def __init__(
+ self,
+ websession: ClientSession,
+ oauth_session: config_entry_oauth2_flow.OAuth2Session,
+ ) -> None:
+ """Initialize AsyncConfigEntryAuth."""
+ super().__init__(websession)
+ self._oauth_session = oauth_session
+
+ async def async_get_access_token(self) -> str:
+ """Return a valid access token."""
+ try:
+ await self._oauth_session.async_ensure_token_valid()
+ except ClientError as ex:
+ if (
+ self._oauth_session.config_entry.state
+ is ConfigEntryState.SETUP_IN_PROGRESS
+ ):
+ if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
+ raise ConfigEntryAuthFailed(
+ "OAuth session is not valid, reauth required"
+ ) from ex
+ raise ConfigEntryNotReady from ex
+ if hasattr(ex, "status") and ex.status == 400:
+ self._oauth_session.config_entry.async_start_reauth(
+ self._oauth_session.hass
+ )
+ raise HomeAssistantError(ex) from ex
+ return str(self._oauth_session.token[CONF_ACCESS_TOKEN])
+
+
+class AsyncConfigFlowAuth(AbstractAuth):
+ """Provide authentication tied to a fixed token for the config flow."""
+
+ def __init__(
+ self,
+ websession: ClientSession,
+ token: str,
+ ) -> None:
+ """Initialize AsyncConfigFlowAuth."""
+ super().__init__(websession)
+ self._token = token
+
+ async def async_get_access_token(self) -> str:
+ """Return a valid access token."""
+ return self._token
+
+
+class DriveClient:
+ """Google Drive client."""
+
+ def __init__(
+ self,
+ ha_instance_id: str,
+ auth: AbstractAuth,
+ ) -> None:
+ """Initialize Google Drive client."""
+ self._ha_instance_id = ha_instance_id
+ self._api = GoogleDriveApi(auth)
+
+ async def async_get_email_address(self) -> str:
+ """Get email address of the current user."""
+ res = await self._api.get_user(params={"fields": "user(emailAddress)"})
+ return str(res["user"]["emailAddress"])
+
+ async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
+ """Create Home Assistant folder if it doesn't exist."""
+ fields = "id,name"
+ query = " and ".join(
+ [
+ "properties has { key='home_assistant' and value='root' }",
+ f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}",
+ "trashed=false",
+ ]
+ )
+ res = await self._api.list_files(
+ params={"q": query, "fields": f"files({fields})"}
+ )
+ for file in res["files"]:
+ _LOGGER.debug("Found existing folder: %s", file)
+ return str(file["id"]), str(file["name"])
+
+ file_metadata = {
+ "name": "Home Assistant",
+ "mimeType": "application/vnd.google-apps.folder",
+ "properties": {
+ "home_assistant": "root",
+ "instance_id": self._ha_instance_id,
+ },
+ }
+ _LOGGER.debug("Creating new folder with metadata: %s", file_metadata)
+ res = await self._api.create_file(params={"fields": fields}, json=file_metadata)
+ _LOGGER.debug("Created folder: %s", res)
+ return str(res["id"]), str(res["name"])
+
+ async def async_upload_backup(
+ self,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ ) -> None:
+ """Upload a backup."""
+ folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
+ backup_metadata = {
+ "name": suggested_filename(backup),
+ "description": json.dumps(backup.as_dict()),
+ "parents": [folder_id],
+ "properties": {
+ "home_assistant": "backup",
+ "instance_id": self._ha_instance_id,
+ "backup_id": backup.backup_id,
+ },
+ }
+ _LOGGER.debug(
+ "Uploading backup: %s with Google Drive metadata: %s",
+ backup.backup_id,
+ backup_metadata,
+ )
+ await self._api.upload_file(
+ backup_metadata,
+ open_stream,
+ timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT),
+ )
+ _LOGGER.debug(
+ "Uploaded backup: %s to: '%s'",
+ backup.backup_id,
+ backup_metadata["name"],
+ )
+
+ async def async_list_backups(self) -> list[AgentBackup]:
+ """List backups."""
+ query = " and ".join(
+ [
+ "properties has { key='home_assistant' and value='backup' }",
+ f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}",
+ "trashed=false",
+ ]
+ )
+ res = await self._api.list_files(
+ params={"q": query, "fields": "files(description)"}
+ )
+ backups = []
+ for file in res["files"]:
+ backup = AgentBackup.from_dict(json.loads(file["description"]))
+ backups.append(backup)
+ return backups
+
+ async def async_get_backup_file_id(self, backup_id: str) -> str | None:
+ """Get file_id of backup if it exists."""
+ query = " and ".join(
+ [
+ "properties has { key='home_assistant' and value='backup' }",
+ f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}",
+ f"properties has {{ key='backup_id' and value='{backup_id}' }}",
+ ]
+ )
+ res = await self._api.list_files(params={"q": query, "fields": "files(id)"})
+ for file in res["files"]:
+ return str(file["id"])
+ return None
+
+ async def async_delete(self, file_id: str) -> None:
+ """Delete file."""
+ await self._api.delete_file(file_id)
+
+ async def async_download(self, file_id: str) -> StreamReader:
+ """Download a file."""
+ resp = await self._api.get_file_content(
+ file_id, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT)
+ )
+ return resp.content
diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py
new file mode 100644
index 00000000000..1c4421623d4
--- /dev/null
+++ b/homeassistant/components/google_drive/application_credentials.py
@@ -0,0 +1,23 @@
+"""application_credentials platform for Google Drive."""
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_entry_oauth2_flow
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ "https://accounts.google.com/o/oauth2/v2/auth",
+ "https://oauth2.googleapis.com/token",
+ )
+
+
+async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
+ """Return description placeholders for the credentials dialog."""
+ return {
+ "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
+ "more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
+ "oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
+ "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass),
+ }
diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py
new file mode 100644
index 00000000000..73e5902f8f5
--- /dev/null
+++ b/homeassistant/components/google_drive/backup.py
@@ -0,0 +1,142 @@
+"""Backup platform for the Google Drive integration."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+import logging
+from typing import Any
+
+from google_drive_api.exceptions import GoogleDriveApiError
+
+from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
+from homeassistant.util import slugify
+
+from . import DATA_BACKUP_AGENT_LISTENERS, GoogleDriveConfigEntry
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+ **kwargs: Any,
+) -> list[BackupAgent]:
+ """Return a list of backup agents."""
+ entries = hass.config_entries.async_loaded_entries(DOMAIN)
+ return [GoogleDriveBackupAgent(entry) for entry in entries]
+
+
+@callback
+def async_register_backup_agents_listener(
+ hass: HomeAssistant,
+ *,
+ listener: Callable[[], None],
+ **kwargs: Any,
+) -> Callable[[], None]:
+ """Register a listener to be called when agents are added or removed.
+
+ :return: A function to unregister the listener.
+ """
+ hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove the listener."""
+ hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
+ if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
+ del hass.data[DATA_BACKUP_AGENT_LISTENERS]
+
+ return remove_listener
+
+
+class GoogleDriveBackupAgent(BackupAgent):
+ """Google Drive backup agent."""
+
+ domain = DOMAIN
+
+ def __init__(self, config_entry: GoogleDriveConfigEntry) -> None:
+ """Initialize the cloud backup sync agent."""
+ super().__init__()
+ assert config_entry.unique_id
+ self.name = config_entry.title
+ self.unique_id = slugify(config_entry.unique_id)
+ self._client = config_entry.runtime_data
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup.
+
+ :param open_stream: A function returning an async iterator that yields bytes.
+ :param backup: Metadata about the backup that should be uploaded.
+ """
+ try:
+ await self._client.async_upload_backup(open_stream, backup)
+ except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
+ raise BackupAgentError(f"Failed to upload backup: {err}") from err
+
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ try:
+ return await self._client.async_list_backups()
+ except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
+ raise BackupAgentError(f"Failed to list backups: {err}") from err
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ backups = await self.async_list_backups()
+ for backup in backups:
+ if backup.backup_id == backup_id:
+ return backup
+ return None
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ :return: An async iterator that yields bytes.
+ """
+ _LOGGER.debug("Downloading backup_id: %s", backup_id)
+ try:
+ file_id = await self._client.async_get_backup_file_id(backup_id)
+ if file_id:
+ _LOGGER.debug("Downloading file_id: %s", file_id)
+ stream = await self._client.async_download(file_id)
+ return ChunkAsyncStreamIterator(stream)
+ except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
+ raise BackupAgentError(f"Failed to download backup: {err}") from err
+ raise BackupAgentError("Backup not found")
+
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ """
+ _LOGGER.debug("Deleting backup_id: %s", backup_id)
+ try:
+ file_id = await self._client.async_get_backup_file_id(backup_id)
+ if file_id:
+ _LOGGER.debug("Deleting file_id: %s", file_id)
+ await self._client.async_delete(file_id)
+ _LOGGER.debug("Deleted backup_id: %s", backup_id)
+ except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
+ raise BackupAgentError(f"Failed to delete backup: {err}") from err
diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py
new file mode 100644
index 00000000000..fb74af42210
--- /dev/null
+++ b/homeassistant/components/google_drive/config_flow.py
@@ -0,0 +1,114 @@
+"""Config flow for the Google Drive integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any, cast
+
+from google_drive_api.exceptions import GoogleDriveApiError
+
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.helpers import config_entry_oauth2_flow, instance_id
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .api import AsyncConfigFlowAuth, DriveClient
+from .const import DOMAIN
+
+DEFAULT_NAME = "Google Drive"
+DRIVE_FOLDER_URL_PREFIX = "https://drive.google.com/drive/folders/"
+OAUTH2_SCOPES = [
+ "https://www.googleapis.com/auth/drive.file",
+]
+
+
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle Google Drive OAuth2 authentication."""
+
+ DOMAIN = DOMAIN
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
+
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {
+ "scope": " ".join(OAUTH2_SCOPES),
+ # Add params to ensure we get back a refresh token
+ "access_type": "offline",
+ "prompt": "consent",
+ }
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ return await self.async_step_user()
+
+ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
+ """Create an entry for the flow, or update existing entry."""
+ client = DriveClient(
+ await instance_id.async_get(self.hass),
+ AsyncConfigFlowAuth(
+ async_get_clientsession(self.hass), data[CONF_TOKEN][CONF_ACCESS_TOKEN]
+ ),
+ )
+
+ try:
+ email_address = await client.async_get_email_address()
+ except GoogleDriveApiError as err:
+ self.logger.error("Error getting email address: %s", err)
+ return self.async_abort(
+ reason="access_not_configured",
+ description_placeholders={"message": str(err)},
+ )
+ except Exception:
+ self.logger.exception("Unknown error occurred")
+ return self.async_abort(reason="unknown")
+
+ await self.async_set_unique_id(email_address)
+
+ if self.source == SOURCE_REAUTH:
+ reauth_entry = self._get_reauth_entry()
+ self._abort_if_unique_id_mismatch(
+ reason="wrong_account",
+ description_placeholders={"email": cast(str, reauth_entry.unique_id)},
+ )
+ return self.async_update_reload_and_abort(reauth_entry, data=data)
+
+ self._abort_if_unique_id_configured()
+
+ try:
+ (
+ folder_id,
+ folder_name,
+ ) = await client.async_create_ha_root_folder_if_not_exists()
+ except GoogleDriveApiError as err:
+ self.logger.error("Error creating folder: %s", str(err))
+ return self.async_abort(
+ reason="create_folder_failure",
+ description_placeholders={"message": str(err)},
+ )
+
+ return self.async_create_entry(
+ title=DEFAULT_NAME,
+ data=data,
+ description_placeholders={
+ "folder_name": folder_name,
+ "url": f"{DRIVE_FOLDER_URL_PREFIX}{folder_id}",
+ },
+ )
diff --git a/homeassistant/components/google_drive/const.py b/homeassistant/components/google_drive/const.py
new file mode 100644
index 00000000000..3f0b3e9d610
--- /dev/null
+++ b/homeassistant/components/google_drive/const.py
@@ -0,0 +1,5 @@
+"""Constants for the Google Drive integration."""
+
+from __future__ import annotations
+
+DOMAIN = "google_drive"
diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json
new file mode 100644
index 00000000000..a1abb9b260a
--- /dev/null
+++ b/homeassistant/components/google_drive/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "google_drive",
+ "name": "Google Drive",
+ "after_dependencies": ["backup"],
+ "codeowners": ["@tronikos"],
+ "config_flow": true,
+ "dependencies": ["application_credentials"],
+ "documentation": "https://www.home-assistant.io/integrations/google_drive",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["google_drive_api"],
+ "quality_scale": "platinum",
+ "requirements": ["python-google-drive-api==0.0.2"]
+}
diff --git a/homeassistant/components/google_drive/quality_scale.yaml b/homeassistant/components/google_drive/quality_scale.yaml
new file mode 100644
index 00000000000..70627a6a6d7
--- /dev/null
+++ b/homeassistant/components/google_drive/quality_scale.yaml
@@ -0,0 +1,113 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No actions.
+ appropriate-polling:
+ status: exempt
+ comment: No polling.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: No actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: No entities.
+ entity-unique-id:
+ status: exempt
+ comment: No entities.
+ has-entity-name:
+ status: exempt
+ comment: No entities.
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No configuration options.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: No entities.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: No entities.
+ parallel-updates:
+ status: exempt
+ comment: No actions and no entities.
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices:
+ status: exempt
+ comment: No devices.
+ diagnostics:
+ status: exempt
+ comment: No data to diagnose.
+ discovery-update-info:
+ status: exempt
+ comment: No discovery.
+ discovery:
+ status: exempt
+ comment: No discovery.
+ docs-data-update:
+ status: exempt
+ comment: No updates.
+ docs-examples:
+ status: exempt
+ comment: |
+ This integration only serves backup.
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: No devices.
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: No devices.
+ entity-category:
+ status: exempt
+ comment: No entities.
+ entity-device-class:
+ status: exempt
+ comment: No entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: No entities.
+ entity-translations:
+ status: exempt
+ comment: No entities.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: No entities.
+ reconfiguration-flow:
+ status: exempt
+ comment: No configuration options.
+ repair-issues:
+ status: exempt
+ comment: No repairs.
+ stale-devices:
+ status: exempt
+ comment: No devices.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json
new file mode 100644
index 00000000000..e6658fb08e9
--- /dev/null
+++ b/homeassistant/components/google_drive/strings.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "step": {
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Google Drive integration needs to re-authenticate your account"
+ },
+ "auth": {
+ "title": "Link Google Account"
+ }
+ },
+ "abort": {
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "access_not_configured": "Unable to access the Google Drive API:\n\n{message}",
+ "create_folder_failure": "Error while creating Google Drive folder:\n\n{message}",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "wrong_account": "Wrong account: Please authenticate with {email}."
+ },
+ "create_entry": {
+ "default": "Using [{folder_name}]({url}) folder. Feel free to rename it in Google Drive as you wish."
+ }
+ },
+ "application_credentials": {
+ "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*."
+ }
+}
diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py
index dad9c8a1920..db2df9cddd3 100644
--- a/homeassistant/components/google_generative_ai_conversation/conversation.py
+++ b/homeassistant/components/google_generative_ai_conversation/conversation.py
@@ -11,18 +11,15 @@ import google.generativeai as genai
from google.generativeai import protos
import google.generativeai.types as genai_types
from google.protobuf.json_format import MessageToDict
-import voluptuous as vol
from voluptuous_openapi import convert
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
-from homeassistant.helpers import device_registry as dr, intent, llm, template
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import ulid
from .const import (
CONF_CHAT_MODEL,
@@ -152,6 +149,17 @@ def _escape_decode(value: Any) -> Any:
return value
+def _chat_message_convert(
+ message: conversation.Content | conversation.NativeContent[genai_types.ContentDict],
+) -> genai_types.ContentDict:
+ """Convert any native chat message for this agent to the native format."""
+ if message.role == "native":
+ return message.content
+
+ role = "model" if message.role == "assistant" else message.role
+ return {"role": role, "parts": message.content}
+
+
class GoogleGenerativeAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
):
@@ -163,7 +171,6 @@ class GoogleGenerativeAIConversationEntity(
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
- self.history: dict[str, list[genai_types.ContentType]] = {}
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -202,49 +209,37 @@ class GoogleGenerativeAIConversationEntity(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
- result = conversation.ConversationResult(
- response=intent.IntentResponse(language=user_input.language),
- conversation_id=user_input.conversation_id or ulid.ulid_now(),
- )
- assert result.conversation_id
+ async with conversation.async_get_chat_session(
+ self.hass, user_input
+ ) as session:
+ return await self._async_handle_message(user_input, session)
- 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,
- )
- llm_api: llm.APIInstance | None = None
- tools: list[dict[str, Any]] | None = None
- if self.entry.options.get(CONF_LLM_HASS_API):
- try:
- llm_api = await llm.async_get_api(
- self.hass,
- self.entry.options[CONF_LLM_HASS_API],
- llm_context,
- )
- except HomeAssistantError as err:
- LOGGER.error("Error getting LLM API: %s", err)
- result.response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Error preparing LLM API: {err}",
- )
- return result
- tools = [
- _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools
- ]
+ async def _async_handle_message(
+ self,
+ user_input: conversation.ConversationInput,
+ session: conversation.ChatSession[genai_types.ContentDict],
+ ) -> conversation.ConversationResult:
+ """Call the API."""
+
+ assert user_input.agent_id
+ options = self.entry.options
try:
- prompt = await self._async_render_prompt(user_input, llm_api, llm_context)
- except TemplateError as err:
- LOGGER.error("Error rendering prompt: %s", err)
- result.response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Sorry, I had a problem with my template: {err}",
+ await session.async_update_llm_data(
+ DOMAIN,
+ user_input,
+ options.get(CONF_LLM_HASS_API),
+ options.get(CONF_PROMPT),
)
- return result
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+
+ tools: list[dict[str, Any]] | None = None
+ if session.llm_api:
+ tools = [
+ _format_tool(tool, session.llm_api.custom_serializer)
+ for tool in session.llm_api.tools
+ ]
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Gemini 1.0 doesn't support system_instruction while 1.5 does.
@@ -254,6 +249,9 @@ class GoogleGenerativeAIConversationEntity(
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
)
+ prompt, *messages = [
+ _chat_message_convert(message) for message in session.async_get_messages()
+ ]
model = genai.GenerativeModel(
model_name=model_name,
generation_config={
@@ -281,27 +279,15 @@ class GoogleGenerativeAIConversationEntity(
),
},
tools=tools or None,
- system_instruction=prompt if supports_system_instruction else None,
+ system_instruction=prompt["parts"] if supports_system_instruction else None,
)
- messages = self.history.get(result.conversation_id, [])
if not supports_system_instruction:
- if not messages:
- messages = [{}, {"role": "model", "parts": "Ok"}]
- messages[0] = {"role": "user", "parts": prompt}
-
- LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages)
- trace.async_conversation_trace_append(
- trace.ConversationTraceEventType.AGENT_DETAIL,
- {
- # Make a copy to attach it to the trace event.
- "messages": messages[:]
- if supports_system_instruction
- else messages[2:],
- "prompt": prompt,
- "tools": [*llm_api.tools] if llm_api else None,
- },
- )
+ messages = [
+ {"role": "user", "parts": prompt["parts"]},
+ {"role": "model", "parts": "Ok"},
+ *messages,
+ ]
chat = model.start_chat(history=messages)
chat_request = user_input.text
@@ -326,24 +312,30 @@ class GoogleGenerativeAIConversationEntity(
f"Sorry, I had a problem talking to Google Generative AI: {err}"
)
- result.response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- error,
- )
- return result
+ raise HomeAssistantError(error) from err
LOGGER.debug("Response: %s", chat_response.parts)
if not chat_response.parts:
- result.response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- "Sorry, I had a problem getting a response from Google Generative AI.",
+ raise HomeAssistantError(
+ "Sorry, I had a problem getting a response from Google Generative AI."
)
- return result
- self.history[result.conversation_id] = chat.history
+ content = " ".join(
+ [part.text.strip() for part in chat_response.parts if part.text]
+ )
+ if content:
+ session.async_add_message(
+ conversation.Content(
+ role="assistant",
+ agent_id=user_input.agent_id,
+ content=content,
+ )
+ )
+
function_calls = [
part.function_call for part in chat_response.parts if part.function_call
]
- if not function_calls or not llm_api:
+
+ if not function_calls or not session.llm_api:
break
tool_responses = []
@@ -351,16 +343,8 @@ class GoogleGenerativeAIConversationEntity(
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
tool_name = tool_call["name"]
tool_args = _escape_decode(tool_call["args"])
- LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args)
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
- try:
- function_response = await llm_api.async_call_tool(tool_input)
- except (HomeAssistantError, vol.Invalid) as e:
- function_response = {"error": type(e).__name__}
- if str(e):
- function_response["error_text"] = str(e)
-
- LOGGER.debug("Tool response: %s", function_response)
+ function_response = await session.async_call_tool(tool_input)
tool_responses.append(
protos.Part(
function_response=protos.FunctionResponse(
@@ -369,47 +353,20 @@ class GoogleGenerativeAIConversationEntity(
)
)
chat_request = protos.Content(parts=tool_responses)
+ session.async_add_message(
+ conversation.NativeContent(
+ agent_id=user_input.agent_id,
+ content=chat_request,
+ )
+ )
- result.response.async_set_speech(
+ response = intent.IntentResponse(language=user_input.language)
+ response.async_set_speech(
" ".join([part.text.strip() for part in chat_response.parts if part.text])
)
- return result
-
- async def _async_render_prompt(
- self,
- user_input: conversation.ConversationInput,
- llm_api: llm.APIInstance | None,
- llm_context: llm.LLMContext,
- ) -> str:
- user_name: str | None = None
- 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
-
- parts = [
- template.Template(
- llm.BASE_PROMPT
- + self.entry.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,
- )
- ]
-
- if llm_api:
- parts.append(llm_api.api_prompt)
-
- return "\n".join(parts)
+ return conversation.ConversationResult(
+ response=response, conversation_id=session.conversation_id
+ )
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py
index 485d640a04d..3e455f645ad 100644
--- a/homeassistant/components/google_mail/api.py
+++ b/homeassistant/components/google_mail/api.py
@@ -49,10 +49,8 @@ class AsyncConfigEntryAuth:
"OAuth session is not valid, reauth required"
) from ex
raise ConfigEntryNotReady from ex
- if (
- isinstance(ex, RefreshError)
- or hasattr(ex, "status")
- and ex.status == 400
+ if isinstance(ex, RefreshError) or (
+ hasattr(ex, "status") and ex.status == 400
):
self.oauth_session.config_entry.async_start_reauth(
self.oauth_session.hass
diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json
index f93a8581e1c..759242593ff 100644
--- a/homeassistant/components/google_mail/strings.json
+++ b/homeassistant/components/google_mail/strings.json
@@ -13,19 +13,19 @@
}
},
"abort": {
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
- "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
- "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "wrong_account": "Wrong account: Please authenticate with {email}.",
- "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
- "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
- "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
+ "wrong_account": "Wrong account: Please authenticate with {email}."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py
index 31eca8fba01..fd50295a6a1 100644
--- a/homeassistant/components/google_maps/device_tracker.py
+++ b/homeassistant/components/google_maps/device_tracker.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py
index f23a706b2e2..22d3cc7deb0 100644
--- a/homeassistant/components/google_photos/services.py
+++ b/homeassistant/components/google_photos/services.py
@@ -144,11 +144,9 @@ def async_register_services(hass: HomeAssistant) -> None:
if call.return_response:
return {
"media_items": [
- {
- "media_item_id": item_result.media_item.id
- for item_result in upload_result.new_media_item_results
- if item_result.media_item and item_result.media_item.id
- }
+ {"media_item_id": item_result.media_item.id}
+ for item_result in upload_result.new_media_item_results
+ if item_result.media_item and item_result.media_item.id
],
"album_id": album_id,
}
diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json
index fa3f4669dac..5695192dd27 100644
--- a/homeassistant/components/google_photos/strings.json
+++ b/homeassistant/components/google_photos/strings.json
@@ -6,23 +6,31 @@
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Google Photos integration needs to re-authenticate your account"
+ },
+ "auth": {
+ "title": "Link Google Account"
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
- "access_not_configured": "Unable to access the Google API:\n\n{message}",
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "wrong_account": "Wrong account: Please authenticate with the right account.",
- "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "access_not_configured": "Unable to access the Google API:\n\n{message}",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "wrong_account": "Wrong account: Please authenticate with the right account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -66,11 +74,11 @@
"services": {
"upload": {
"name": "Upload media",
- "description": "Upload images or videos to Google Photos.",
+ "description": "Uploads images or videos to Google Photos.",
"fields": {
"config_entry_id": {
- "name": "Integration Id",
- "description": "The Google Photos integration id."
+ "name": "Integration ID",
+ "description": "The Google Photos integration ID."
},
"filename": {
"name": "Filename",
diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py
index f289fae2456..ace56bf9354 100644
--- a/homeassistant/components/google_pubsub/__init__.py
+++ b/homeassistant/components/google_pubsub/__init__.py
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py
index 3f34b23d522..942db675b5a 100644
--- a/homeassistant/components/google_sheets/__init__.py
+++ b/homeassistant/components/google_sheets/__init__.py
@@ -20,11 +20,11 @@ from homeassistant.exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
)
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import DEFAULT_ACCESS, DOMAIN
diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json
index d8cb06d9bcd..406c4440d00 100644
--- a/homeassistant/components/google_sheets/strings.json
+++ b/homeassistant/components/google_sheets/strings.json
@@ -4,27 +4,29 @@
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
- "auth": { "title": "Link Google Account" },
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Google Sheets integration needs to re-authenticate your account"
+ },
+ "auth": {
+ "title": "Link Google Account"
}
},
"abort": {
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
- "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
- "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
- "unknown": "[%key:common::config_flow::error::unknown%]",
"create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details",
- "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details",
- "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
- "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
- "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
+ "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details"
},
"create_entry": {
"default": "Successfully authenticated and spreadsheet created at: {url}"
diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json
index a26cf8c58ec..b58678f6d30 100644
--- a/homeassistant/components/google_tasks/strings.json
+++ b/homeassistant/components/google_tasks/strings.json
@@ -6,23 +6,31 @@
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Google Tasks integration needs to re-authenticate your account"
+ },
+ "auth": {
+ "title": "Link Google Account"
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
- "access_not_configured": "Unable to access the Google API:\n\n{message}",
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "wrong_account": "Wrong account: Please authenticate with the right account.",
- "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "access_not_configured": "Unable to access the Google API:\n\n{message}",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "wrong_account": "Wrong account: Please authenticate with the right account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py
index ed9709d2811..ab0291bc58f 100644
--- a/homeassistant/components/google_translate/const.py
+++ b/homeassistant/components/google_translate/const.py
@@ -88,6 +88,7 @@ SUPPORT_LANGUAGES = [
"uk",
"ur",
"vi",
+ "yue",
# dialects
"zh-CN",
"zh-cn",
diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json
index 7074d0ed444..b5b1f670675 100644
--- a/homeassistant/components/google_translate/manifest.json
+++ b/homeassistant/components/google_translate/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_translate",
"iot_class": "cloud_push",
"loggers": ["gtts"],
- "requirements": ["gTTS==2.2.4"]
+ "requirements": ["gTTS==2.5.3"]
}
diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py
index 08de293bc7d..a29d3d75b3e 100644
--- a/homeassistant/components/google_travel_time/config_flow.py
+++ b/homeassistant/components/google_travel_time/config_flow.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
index a764036321b..a3f9c236136 100644
--- a/homeassistant/components/google_travel_time/sensor.py
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.location import find_coordinates
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
ATTRIBUTION,
diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py
index 3dd421d99da..6ce1c49410f 100644
--- a/homeassistant/components/google_wifi/sensor.py
+++ b/homeassistant/components/google_wifi/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py
index bd92093c29c..7b7a1fb5a50 100644
--- a/homeassistant/components/govee_ble/binary_sensor.py
+++ b/homeassistant/components/govee_ble/binary_sensor.py
@@ -39,6 +39,10 @@ BINARY_SENSOR_DESCRIPTIONS = {
key=GoveeBLEBinarySensorDeviceClass.OCCUPANCY,
device_class=BinarySensorDeviceClass.OCCUPANCY,
),
+ GoveeBLEBinarySensorDeviceClass.PRESENCE: BinarySensorEntityDescription(
+ key=GoveeBLEBinarySensorDeviceClass.PRESENCE,
+ device_class=BinarySensorDeviceClass.PRESENCE,
+ ),
}
diff --git a/homeassistant/components/govee_ble/config_flow.py b/homeassistant/components/govee_ble/config_flow.py
index 2cc47435abf..d48fffdd633 100644
--- a/homeassistant/components/govee_ble/config_flow.py
+++ b/homeassistant/components/govee_ble/config_flow.py
@@ -78,7 +78,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
title=title, data={CONF_DEVICE_TYPE: device.device_type}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py
index 55275477164..5e5aa6354be 100644
--- a/homeassistant/components/govee_ble/event.py
+++ b/homeassistant/components/govee_ble/event.py
@@ -102,8 +102,7 @@ async def async_setup_entry(
descriptions = [MOTION_DESCRIPTION]
elif sensor_type is SensorType.VIBRATION:
descriptions = [VIBRATION_DESCRIPTION]
- elif sensor_type is SensorType.BUTTON:
- button_count = model_info.button_count
+ elif button_count := model_info.button_count:
descriptions = BUTTON_DESCRIPTIONS[0:button_count]
else:
return
diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json
index 39a66ad36a7..5a123de7066 100644
--- a/homeassistant/components/govee_ble/manifest.json
+++ b/homeassistant/components/govee_ble/manifest.json
@@ -42,6 +42,10 @@
"local_name": "GVH5127*",
"connectable": false
},
+ {
+ "local_name": "GVH5130*",
+ "connectable": false
+ },
{
"manufacturer_id": 1,
"service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb",
@@ -127,5 +131,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
- "requirements": ["govee-ble==0.40.0"]
+ "requirements": ["govee-ble==0.42.0"]
}
diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py
index 50a98e277a6..7c7612ed201 100644
--- a/homeassistant/components/gpslogger/__init__.py
+++ b/homeassistant/components/gpslogger/__init__.py
@@ -10,8 +10,7 @@ from homeassistant.components.device_tracker import ATTR_BATTERY
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py
index 336ca6ba2cb..8d1864f5522 100644
--- a/homeassistant/components/graphite/__init__.py
+++ b/homeassistant/components/graphite/__init__.py
@@ -19,8 +19,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py
index 083d431e338..e3acbcd56e9 100644
--- a/homeassistant/components/greeneye_monitor/__init__.py
+++ b/homeassistant/components/greeneye_monitor/__init__.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import Event, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py
index 89d3ca3a535..9b7a3cf29ea 100644
--- a/homeassistant/components/greenwave/light.py
+++ b/homeassistant/components/greenwave/light.py
@@ -18,7 +18,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py
index 03a8be4bed5..40db70a2eb3 100644
--- a/homeassistant/components/group/entity.py
+++ b/homeassistant/components/group/entity.py
@@ -440,10 +440,8 @@ class Group(Entity):
if not self._on_off:
return
- if (
- tr_state is None
- or self._assumed_state
- and not tr_state.attributes.get(ATTR_ASSUMED_STATE)
+ if tr_state is None or (
+ self._assumed_state and not tr_state.attributes.get(ATTR_ASSUMED_STATE)
):
self._assumed_state = self.mode(self._assumed.values())
diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py
index fdef327cb73..d6a9a6fd3c7 100644
--- a/homeassistant/components/group/notify.py
+++ b/homeassistant/components/group/notify.py
@@ -28,9 +28,8 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json
index cf694af0d98..fb90eb9b22c 100644
--- a/homeassistant/components/group/strings.json
+++ b/homeassistant/components/group/strings.json
@@ -238,7 +238,7 @@
},
"set": {
"name": "Set",
- "description": "Creates/Updates a user group.",
+ "description": "Creates/Updates a group.",
"fields": {
"object_id": {
"name": "Object ID",
diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py
index fd9de62c016..bb78aff8faf 100644
--- a/homeassistant/components/gstreamer/media_player.py
+++ b/homeassistant/components/gstreamer/media_player.py
@@ -20,7 +20,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py
index fbc65050704..2637a55f772 100644
--- a/homeassistant/components/gtfs/sensor.py
+++ b/homeassistant/components/gtfs/sensor.py
@@ -19,11 +19,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, CONF_OFFSET, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, slugify
_LOGGER = logging.getLogger(__name__)
@@ -342,7 +341,7 @@ def get_next_departure(
{tomorrow_order}
origin_stop_time.departure_time
LIMIT :limit
- """ # noqa: S608
+ """
result = schedule.engine.connect().execute(
text(sql_query),
{
diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py
index c4146d72469..55e4893e31b 100644
--- a/homeassistant/components/guardian/config_flow.py
+++ b/homeassistant/components/guardian/config_flow.py
@@ -8,10 +8,11 @@ from aioguardian import Client
from aioguardian.errors import GuardianError
import voluptuous as vol
-from homeassistant.components import dhcp, zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_UID, DOMAIN, LOGGER
@@ -101,7 +102,7 @@ class GuardianConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle the configuration via dhcp."""
self.discovery_info = {
@@ -114,7 +115,7 @@ class GuardianConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle the configuration via zeroconf."""
self.discovery_info = {
diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py
index 5843e14d63e..1972e89c58a 100644
--- a/homeassistant/components/habitica/__init__.py
+++ b/homeassistant/components/habitica/__init__.py
@@ -1,27 +1,14 @@
"""The habitica integration."""
-from http import HTTPStatus
+from habiticalib import Habitica
-from aiohttp import ClientResponseError
-from habitipy.aio import HabitipyAsync
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- APPLICATION_NAME,
- CONF_API_KEY,
- CONF_NAME,
- CONF_URL,
- CONF_VERIFY_SSL,
- Platform,
- __version__,
-)
+from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, 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 homeassistant.helpers.typing import ConfigType
-from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN
+from .const import CONF_API_USER, DOMAIN, X_CLIENT
from .coordinator import HabiticaDataUpdateCoordinator
from .services import async_setup_services
from .types import HabiticaConfigEntry
@@ -33,6 +20,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
+ Platform.IMAGE,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
@@ -51,47 +39,17 @@ async def async_setup_entry(
) -> bool:
"""Set up habitica from a config entry."""
- class HAHabitipyAsync(HabitipyAsync):
- """Closure API class to hold session."""
-
- def __call__(self, **kwargs):
- return super().__call__(websession, **kwargs)
-
- def _make_headers(self) -> dict[str, str]:
- headers = super()._make_headers()
- headers.update(
- {"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"}
- )
- return headers
-
- websession = async_get_clientsession(
+ session = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
- api = await hass.async_add_executor_job(
- HAHabitipyAsync,
- {
- "url": config_entry.data[CONF_URL],
- "login": config_entry.data[CONF_API_USER],
- "password": config_entry.data[CONF_API_KEY],
- },
+ api = Habitica(
+ session,
+ api_user=config_entry.data[CONF_API_USER],
+ api_key=config_entry.data[CONF_API_KEY],
+ url=config_entry.data[CONF_URL],
+ x_client=X_CLIENT,
)
- try:
- user = await api.user.get(userFields="profile")
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- raise ConfigEntryNotReady(e) from e
-
- if not config_entry.data.get(CONF_NAME):
- name = user["profile"]["name"]
- hass.config_entries.async_update_entry(
- config_entry,
- data={**config_entry.data, CONF_NAME: name},
- )
coordinator = HabiticaDataUpdateCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
@@ -102,6 +60,6 @@ async def async_setup_entry(
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py
index bc79370ea63..5e3040e0606 100644
--- a/homeassistant/components/habitica/binary_sensor.py
+++ b/homeassistant/components/habitica/binary_sensor.py
@@ -5,7 +5,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
-from typing import Any
+
+from habiticalib import UserData
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
@@ -18,13 +19,15 @@ from .const import ASSETS_URL
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Habitica Binary Sensor Description."""
- value_fn: Callable[[dict[str, Any]], bool | None]
- entity_picture: Callable[[dict[str, Any]], str | None]
+ value_fn: Callable[[UserData], bool | None]
+ entity_picture: Callable[[UserData], str | None]
class HabiticaBinarySensor(StrEnum):
@@ -33,10 +36,10 @@ class HabiticaBinarySensor(StrEnum):
PENDING_QUEST = "pending_quest"
-def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None:
+def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None:
"""Entity picture for pending quest invitation."""
- if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]:
- return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png"
+ if user.party.quest.key and user.party.quest.RSVPNeeded:
+ return f"inventory_quest_scroll_{user.party.quest.key}.png"
return None
@@ -44,7 +47,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] =
HabiticaBinarySensorEntityDescription(
key=HabiticaBinarySensor.PENDING_QUEST,
translation_key=HabiticaBinarySensor.PENDING_QUEST,
- value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"],
+ value_fn=lambda user: user.party.quest.RSVPNeeded,
entity_picture=get_scroll_image_for_pending_quest_invitation,
),
)
diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py
index 2b9a4199133..450a5cdcf20 100644
--- a/homeassistant/components/habitica/button.py
+++ b/homeassistant/components/habitica/button.py
@@ -5,10 +5,17 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
-from http import HTTPStatus
from typing import Any
-from aiohttp import ClientResponseError
+from aiohttp import ClientError
+from habiticalib import (
+ HabiticaClass,
+ HabiticaException,
+ NotAuthorizedError,
+ Skill,
+ TaskType,
+ TooManyRequestsError,
+)
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
@@ -20,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR
+from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
@@ -34,11 +41,11 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription):
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
available_fn: Callable[[HabiticaData], bool]
- class_needed: str | None = None
+ class_needed: HabiticaClass | None = None
entity_picture: str | None = None
-class HabitipyButtonEntity(StrEnum):
+class HabiticaButtonEntity(StrEnum):
"""Habitica button entities."""
RUN_CRON = "run_cron"
@@ -61,205 +68,207 @@ class HabitipyButtonEntity(StrEnum):
BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.RUN_CRON,
- translation_key=HabitipyButtonEntity.RUN_CRON,
- press_fn=lambda coordinator: coordinator.api.cron.post(),
- available_fn=lambda data: data.user["needsCron"],
+ key=HabiticaButtonEntity.RUN_CRON,
+ translation_key=HabiticaButtonEntity.RUN_CRON,
+ press_fn=lambda coordinator: coordinator.habitica.run_cron(),
+ available_fn=lambda data: data.user.needsCron is True,
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.BUY_HEALTH_POTION,
- translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION,
- press_fn=(
- lambda coordinator: coordinator.api["user"]["buy-health-potion"].post()
- ),
+ key=HabiticaButtonEntity.BUY_HEALTH_POTION,
+ translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION,
+ press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
available_fn=(
- lambda data: data.user["stats"]["gp"] >= 25
- and data.user["stats"]["hp"] < 50
+ lambda data: (data.user.stats.gp or 0) >= 25
+ and (data.user.stats.hp or 0) < 50
),
entity_picture="shop_potion.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
- translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
- press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(),
+ key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
+ translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
+ press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
available_fn=(
- lambda data: data.user["preferences"].get("automaticAllocation") is True
- and data.user["stats"]["points"] > 0
+ lambda data: data.user.preferences.automaticAllocation is True
+ and (data.user.stats.points or 0) > 0
),
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.REVIVE,
- translation_key=HabitipyButtonEntity.REVIVE,
- press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(),
- available_fn=lambda data: data.user["stats"]["hp"] == 0,
+ key=HabiticaButtonEntity.REVIVE,
+ translation_key=HabiticaButtonEntity.REVIVE,
+ press_fn=lambda coordinator: coordinator.habitica.revive(),
+ available_fn=lambda data: data.user.stats.hp == 0,
),
)
CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.MPHEAL,
- translation_key=HabitipyButtonEntity.MPHEAL,
- press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(),
- available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 12
- and data.user["stats"]["mp"] >= 30
+ key=HabiticaButtonEntity.MPHEAL,
+ translation_key=HabiticaButtonEntity.MPHEAL,
+ press_fn=(
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
),
- class_needed=MAGE,
+ available_fn=(
+ lambda data: (data.user.stats.lvl or 0) >= 12
+ and (data.user.stats.mp or 0) >= 30
+ ),
+ class_needed=HabiticaClass.MAGE,
entity_picture="shop_mpheal.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.EARTH,
- translation_key=HabitipyButtonEntity.EARTH,
- press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(),
+ key=HabiticaButtonEntity.EARTH,
+ translation_key=HabiticaButtonEntity.EARTH,
+ press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 13
- and data.user["stats"]["mp"] >= 35
+ lambda data: (data.user.stats.lvl or 0) >= 13
+ and (data.user.stats.mp or 0) >= 35
),
- class_needed=MAGE,
+ class_needed=HabiticaClass.MAGE,
entity_picture="shop_earth.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.FROST,
- translation_key=HabitipyButtonEntity.FROST,
- press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(),
+ key=HabiticaButtonEntity.FROST,
+ translation_key=HabiticaButtonEntity.FROST,
+ press_fn=(
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
+ ),
# chilling frost can only be cast once per day (streaks buff is false)
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 14
- and data.user["stats"]["mp"] >= 40
- and not data.user["stats"]["buffs"]["streaks"]
+ lambda data: (data.user.stats.lvl or 0) >= 14
+ and (data.user.stats.mp or 0) >= 40
+ and not data.user.stats.buffs.streaks
),
- class_needed=MAGE,
+ class_needed=HabiticaClass.MAGE,
entity_picture="shop_frost.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.DEFENSIVE_STANCE,
- translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE,
+ key=HabiticaButtonEntity.DEFENSIVE_STANCE,
+ translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE,
press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast[
- "defensiveStance"
- ].post()
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 12
- and data.user["stats"]["mp"] >= 25
+ lambda data: (data.user.stats.lvl or 0) >= 12
+ and (data.user.stats.mp or 0) >= 25
),
- class_needed=WARRIOR,
+ class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_defensiveStance.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.VALOROUS_PRESENCE,
- translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE,
+ key=HabiticaButtonEntity.VALOROUS_PRESENCE,
+ translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE,
press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast[
- "valorousPresence"
- ].post()
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 13
- and data.user["stats"]["mp"] >= 20
+ lambda data: (data.user.stats.lvl or 0) >= 13
+ and (data.user.stats.mp or 0) >= 20
),
- class_needed=WARRIOR,
+ class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_valorousPresence.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.INTIMIDATE,
- translation_key=HabitipyButtonEntity.INTIMIDATE,
+ key=HabiticaButtonEntity.INTIMIDATE,
+ translation_key=HabiticaButtonEntity.INTIMIDATE,
press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post()
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 14
- and data.user["stats"]["mp"] >= 15
+ lambda data: (data.user.stats.lvl or 0) >= 14
+ and (data.user.stats.mp or 0) >= 15
),
- class_needed=WARRIOR,
+ class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_intimidate.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.TOOLS_OF_TRADE,
- translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE,
+ key=HabiticaButtonEntity.TOOLS_OF_TRADE,
+ translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE,
press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post()
+ lambda coordinator: coordinator.habitica.cast_skill(
+ Skill.TOOLS_OF_THE_TRADE
+ )
),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 13
- and data.user["stats"]["mp"] >= 25
+ lambda data: (data.user.stats.lvl or 0) >= 13
+ and (data.user.stats.mp or 0) >= 25
),
- class_needed=ROGUE,
+ class_needed=HabiticaClass.ROGUE,
entity_picture="shop_toolsOfTrade.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.STEALTH,
- translation_key=HabitipyButtonEntity.STEALTH,
- press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast["stealth"].post()
- ),
+ key=HabiticaButtonEntity.STEALTH,
+ translation_key=HabiticaButtonEntity.STEALTH,
+ press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
# Stealth buffs stack and it can only be cast if the amount of
- # unfinished dailies is smaller than the amount of buffs
+ # buffs is smaller than the amount of unfinished dailies
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 14
- and data.user["stats"]["mp"] >= 45
- and data.user["stats"]["buffs"]["stealth"]
+ lambda data: (data.user.stats.lvl or 0) >= 14
+ and (data.user.stats.mp or 0) >= 45
+ and (data.user.stats.buffs.stealth or 0)
< len(
[
r
for r in data.tasks
- if r.get("type") == "daily"
- and r.get("isDue") is True
- and r.get("completed") is False
+ if r.Type is TaskType.DAILY
+ and r.isDue is True
+ and r.completed is False
]
)
),
- class_needed=ROGUE,
+ class_needed=HabiticaClass.ROGUE,
entity_picture="shop_stealth.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.HEAL,
- translation_key=HabitipyButtonEntity.HEAL,
- press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(),
- available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 11
- and data.user["stats"]["mp"] >= 15
- and data.user["stats"]["hp"] < 50
+ key=HabiticaButtonEntity.HEAL,
+ translation_key=HabiticaButtonEntity.HEAL,
+ press_fn=(
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
),
- class_needed=HEALER,
+ available_fn=(
+ lambda data: (data.user.stats.lvl or 0) >= 11
+ and (data.user.stats.mp or 0) >= 15
+ and (data.user.stats.hp or 0) < 50
+ ),
+ class_needed=HabiticaClass.HEALER,
entity_picture="shop_heal.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.BRIGHTNESS,
- translation_key=HabitipyButtonEntity.BRIGHTNESS,
+ key=HabiticaButtonEntity.BRIGHTNESS,
+ translation_key=HabiticaButtonEntity.BRIGHTNESS,
press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast["brightness"].post()
+ lambda coordinator: coordinator.habitica.cast_skill(
+ Skill.SEARING_BRIGHTNESS
+ )
),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 12
- and data.user["stats"]["mp"] >= 15
+ lambda data: (data.user.stats.lvl or 0) >= 12
+ and (data.user.stats.mp or 0) >= 15
),
- class_needed=HEALER,
+ class_needed=HabiticaClass.HEALER,
entity_picture="shop_brightness.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.PROTECT_AURA,
- translation_key=HabitipyButtonEntity.PROTECT_AURA,
+ key=HabiticaButtonEntity.PROTECT_AURA,
+ translation_key=HabiticaButtonEntity.PROTECT_AURA,
press_fn=(
- lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post()
+ lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 13
- and data.user["stats"]["mp"] >= 30
+ lambda data: (data.user.stats.lvl or 0) >= 13
+ and (data.user.stats.mp or 0) >= 30
),
- class_needed=HEALER,
+ class_needed=HabiticaClass.HEALER,
entity_picture="shop_protectAura.png",
),
HabiticaButtonEntityDescription(
- key=HabitipyButtonEntity.HEAL_ALL,
- translation_key=HabitipyButtonEntity.HEAL_ALL,
- press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(),
+ key=HabiticaButtonEntity.HEAL_ALL,
+ translation_key=HabiticaButtonEntity.HEAL_ALL,
+ press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
available_fn=(
- lambda data: data.user["stats"]["lvl"] >= 14
- and data.user["stats"]["mp"] >= 25
+ lambda data: (data.user.stats.lvl or 0) >= 14
+ and (data.user.stats.mp or 0) >= 25
),
- class_needed=HEALER,
+ class_needed=HabiticaClass.HEALER,
entity_picture="shop_healAll.png",
),
)
@@ -285,10 +294,10 @@ async def async_setup_entry(
for description in CLASS_SKILLS:
if (
- coordinator.data.user["stats"]["lvl"] >= 10
- and coordinator.data.user["flags"]["classSelected"]
- and not coordinator.data.user["preferences"]["disableClasses"]
- and description.class_needed == coordinator.data.user["stats"]["class"]
+ (coordinator.data.user.stats.lvl or 0) >= 10
+ and coordinator.data.user.flags.classSelected
+ and not coordinator.data.user.preferences.disableClasses
+ and description.class_needed is coordinator.data.user.stats.Class
):
if description.key not in skills_added:
buttons.append(HabiticaButton(coordinator, description))
@@ -322,20 +331,28 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- if e.status == HTTPStatus.UNAUTHORIZED:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="service_call_unallowed",
- ) from e
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except NotAuthorizedError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_unallowed",
+ ) from e
+ except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+ translation_placeholders={"reason": e.error.message},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
) from e
else:
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py
index ff483b71fd8..f33f3c3c12f 100644
--- a/homeassistant/components/habitica/calendar.py
+++ b/homeassistant/components/habitica/calendar.py
@@ -3,10 +3,14 @@
from __future__ import annotations
from abc import abstractmethod
+from dataclasses import asdict
from datetime import date, datetime, timedelta
from enum import StrEnum
+from typing import TYPE_CHECKING, cast
+from uuid import UUID
from dateutil.rrule import rrule
+from habiticalib import Frequency, TaskType
from homeassistant.components.calendar import (
CalendarEntity,
@@ -20,9 +24,10 @@ from homeassistant.util import dt as dt_util
from . import HabiticaConfigEntry
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
-from .types import HabiticaTaskType
from .util import build_rrule, get_recurrence_rule
+PARALLEL_UPDATES = 1
+
class HabiticaCalendar(StrEnum):
"""Habitica calendars."""
@@ -83,18 +88,18 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
@property
def start_of_today(self) -> datetime:
"""Habitica daystart."""
- return dt_util.start_of_local_day(
- datetime.fromisoformat(self.coordinator.data.user["lastCron"])
- )
+ return dt_util.start_of_local_day(self.coordinator.data.user.lastCron)
def get_recurrence_dates(
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
) -> list[datetime]:
"""Calculate recurrence dates based on start_date and end_date."""
if end_date:
- return recurrences.between(
+ recurrence_dates = recurrences.between(
start_date, end_date - timedelta(days=1), inc=True
)
+
+ return cast(list[datetime], recurrence_dates)
# if no end_date is given, return only the next recurrence
return [recurrences.after(start_date, inc=True)]
@@ -115,13 +120,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
if not (
- task["type"] == HabiticaTaskType.TODO
- and not task["completed"]
- and task.get("date") # only if has due date
+ task.Type is TaskType.TODO
+ and not task.completed
+ and task.date is not None # only if has due date
):
continue
- start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"]))
+ start = dt_util.start_of_local_day(task.date)
end = start + timedelta(days=1)
# return current and upcoming events or events within the requested range
@@ -132,21 +137,23 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
if end_date and start > end_date:
# Event starts after date range
continue
-
+ if TYPE_CHECKING:
+ assert task.text
+ assert task.id
events.append(
CalendarEvent(
start=start.date(),
end=end.date(),
- summary=task["text"],
- description=task["notes"],
- uid=task["id"],
+ summary=task.text,
+ description=task.notes,
+ uid=str(task.id),
)
)
return sorted(
events,
key=lambda event: (
event.start,
- self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid),
+ self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)),
),
)
@@ -189,7 +196,11 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
# only dailies that that are not 'grey dailies'
- if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
+ if not (task.Type is TaskType.DAILY and task.everyX):
+ continue
+ if task.frequency is Frequency.WEEKLY and not any(
+ asdict(task.repeat).values()
+ ):
continue
recurrences = build_rrule(task)
@@ -199,19 +210,21 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
for recurrence in recurrence_dates:
is_future_event = recurrence > self.start_of_today
is_current_event = (
- recurrence <= self.start_of_today and not task["completed"]
+ recurrence <= self.start_of_today and not task.completed
)
if not is_future_event and not is_current_event:
continue
-
+ if TYPE_CHECKING:
+ assert task.text
+ assert task.id
events.append(
CalendarEvent(
start=recurrence.date(),
end=self.end_date(recurrence, end_date),
- summary=task["text"],
- description=task["notes"],
- uid=task["id"],
+ summary=task.text,
+ description=task.notes,
+ uid=str(task.id),
rrule=get_recurrence_rule(recurrences),
)
)
@@ -219,7 +232,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events,
key=lambda event: (
event.start,
- self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid),
+ self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)),
),
)
@@ -254,14 +267,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
- if task["type"] != HabiticaTaskType.TODO or task["completed"]:
+ if task.Type is not TaskType.TODO or task.completed:
continue
- for reminder in task.get("reminders", []):
+ for reminder in task.reminders:
# reminders are returned by the API in local time but with wrong
# timezone (UTC) and arbitrary added seconds/microseconds. When
# creating reminders in Habitica only hours and minutes can be defined.
- start = datetime.fromisoformat(reminder["time"]).replace(
+ start = reminder.time.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
)
end = start + timedelta(hours=1)
@@ -273,14 +286,16 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
if end_date and start > end_date:
# Event starts after date range
continue
-
+ if TYPE_CHECKING:
+ assert task.text
+ assert task.id
events.append(
CalendarEvent(
start=start,
end=end,
- summary=task["text"],
- description=task["notes"],
- uid=f"{task["id"]}_{reminder["id"]}",
+ summary=task.text,
+ description=task.notes,
+ uid=f"{task.id}_{reminder.id}",
)
)
@@ -298,7 +313,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.DAILY_REMINDERS,
)
- def start(self, reminder_time: str, reminder_date: date) -> datetime:
+ def start(self, reminder_time: datetime, reminder_date: date) -> datetime:
"""Generate reminder times for dailies.
Reminders for dailies have a datetime but the date part is arbitrary,
@@ -307,12 +322,10 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
"""
return datetime.combine(
reminder_date,
- datetime.fromisoformat(reminder_time)
- .replace(
+ reminder_time.replace(
second=0,
microsecond=0,
- )
- .time(),
+ ).time(),
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
@@ -327,7 +340,12 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
start_date = max(start_date, self.start_of_today)
for task in self.coordinator.data.tasks:
- if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
+ if not (task.Type is TaskType.DAILY and task.everyX):
+ continue
+
+ if task.frequency is Frequency.WEEKLY and not any(
+ asdict(task.repeat).values()
+ ):
continue
recurrences = build_rrule(task)
@@ -339,27 +357,30 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
for recurrence in recurrence_dates:
is_future_event = recurrence > self.start_of_today
is_current_event = (
- recurrence <= self.start_of_today and not task["completed"]
+ recurrence <= self.start_of_today and not task.completed
)
if not is_future_event and not is_current_event:
continue
- for reminder in task.get("reminders", []):
- start = self.start(reminder["time"], recurrence)
+ for reminder in task.reminders:
+ start = self.start(reminder.time, recurrence)
end = start + timedelta(hours=1)
if end < start_date:
# Event ends before date range
continue
+ if TYPE_CHECKING:
+ assert task.id
+ assert task.text
events.append(
CalendarEvent(
start=start,
end=end,
- summary=task["text"],
- description=task["notes"],
- uid=f"{task["id"]}_{reminder["id"]}",
+ summary=task.text,
+ description=task.notes,
+ uid=f"{task.id}_{reminder.id}",
)
)
diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py
index d168a5f57b4..7a7f369cb09 100644
--- a/homeassistant/components/habitica/config_flow.py
+++ b/homeassistant/components/habitica/config_flow.py
@@ -2,17 +2,25 @@
from __future__ import annotations
-from http import HTTPStatus
+from collections.abc import Mapping
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
-from aiohttp import ClientResponseError
-from habitipy.aio import HabitipyAsync
+from aiohttp import ClientError
+from habiticalib import (
+ Habitica,
+ HabiticaException,
+ LoginData,
+ NotAuthorizedError,
+ UserData,
+)
import voluptuous as vol
+from homeassistant import data_entry_flow
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
+ CONF_NAME,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
@@ -25,14 +33,19 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
+from . import HabiticaConfigEntry
from .const import (
CONF_API_USER,
DEFAULT_URL,
DOMAIN,
FORGOT_PASSWORD_URL,
HABITICANS_URL,
+ SECTION_DANGER_ZONE,
+ SECTION_REAUTH_API_KEY,
+ SECTION_REAUTH_LOGIN,
SIGN_UP_URL,
SITE_DATA_URL,
+ X_CLIENT,
)
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
@@ -61,14 +74,59 @@ STEP_LOGIN_DATA_SCHEMA = vol.Schema(
}
)
+STEP_REAUTH_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(SECTION_REAUTH_LOGIN): data_entry_flow.section(
+ vol.Schema(
+ {
+ vol.Optional(CONF_USERNAME): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL,
+ autocomplete="email",
+ )
+ ),
+ vol.Optional(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
+ },
+ ),
+ {"collapsed": False},
+ ),
+ vol.Required(SECTION_REAUTH_API_KEY): data_entry_flow.section(
+ vol.Schema(
+ {
+ vol.Optional(CONF_API_KEY): str,
+ },
+ ),
+ {"collapsed": True},
+ ),
+ }
+)
+
+STEP_RECONF_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ vol.Required(SECTION_DANGER_ZONE): data_entry_flow.section(
+ vol.Schema(
+ {
+ vol.Required(CONF_URL): str,
+ vol.Required(CONF_VERIFY_SSL): bool,
+ },
+ ),
+ {"collapsed": True},
+ ),
+ }
+)
+
_LOGGER = logging.getLogger(__name__)
class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for habitica."""
- VERSION = 1
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -93,39 +151,20 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
- try:
- session = async_get_clientsession(self.hass)
- api = await self.hass.async_add_executor_job(
- HabitipyAsync,
- {
- "login": "",
- "password": "",
- "url": DEFAULT_URL,
- },
- )
- login_response = await api.user.auth.local.login.post(
- session=session,
- username=user_input[CONF_USERNAME],
- password=user_input[CONF_PASSWORD],
- )
-
- except ClientResponseError as ex:
- if ex.status == HTTPStatus.UNAUTHORIZED:
- errors["base"] = "invalid_auth"
- else:
- errors["base"] = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
- await self.async_set_unique_id(login_response["id"])
+ errors, login, user = await self.validate_login(
+ {**user_input, CONF_URL: DEFAULT_URL}
+ )
+ if not errors and login is not None and user is not None:
+ await self.async_set_unique_id(str(login.id))
self._abort_if_unique_id_configured()
+ if TYPE_CHECKING:
+ assert user.profile.name
return self.async_create_entry(
- title=login_response["username"],
+ title=user.profile.name,
data={
- CONF_API_USER: login_response["id"],
- CONF_API_KEY: login_response["apiToken"],
- CONF_USERNAME: login_response["username"],
+ CONF_API_USER: str(login.id),
+ CONF_API_KEY: login.apiToken,
+ CONF_NAME: user.profile.name, # needed for api_call action
CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True,
},
@@ -150,36 +189,19 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
- try:
- session = async_get_clientsession(
- self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
- )
- api = await self.hass.async_add_executor_job(
- HabitipyAsync,
- {
- "login": user_input[CONF_API_USER],
- "password": user_input[CONF_API_KEY],
- "url": user_input.get(CONF_URL, DEFAULT_URL),
- },
- )
- api_response = await api.user.get(
- session=session,
- userFields="auth",
- )
- except ClientResponseError as ex:
- if ex.status == HTTPStatus.UNAUTHORIZED:
- errors["base"] = "invalid_auth"
- else:
- errors["base"] = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
- await self.async_set_unique_id(user_input[CONF_API_USER])
- self._abort_if_unique_id_configured()
- user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"]
+ await self.async_set_unique_id(user_input[CONF_API_USER])
+ self._abort_if_unique_id_configured()
+ errors, user = await self.validate_api_key(user_input)
+ if not errors and user is not None:
+ if TYPE_CHECKING:
+ assert user.profile.name
return self.async_create_entry(
- title=user_input[CONF_USERNAME], data=user_input
+ title=user.profile.name,
+ data={
+ **user_input,
+ CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
+ CONF_NAME: user.profile.name, # needed for api_call action
+ },
)
return self.async_show_form(
@@ -193,3 +215,164 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"default_url": DEFAULT_URL,
},
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that reauth is required."""
+ errors: dict[str, str] = {}
+ reauth_entry: HabiticaConfigEntry = self._get_reauth_entry()
+
+ if user_input is not None:
+ if user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME) and user_input[
+ SECTION_REAUTH_LOGIN
+ ].get(CONF_PASSWORD):
+ errors, login, _ = await self.validate_login(
+ {**reauth_entry.data, **user_input[SECTION_REAUTH_LOGIN]}
+ )
+ if not errors and login is not None:
+ await self.async_set_unique_id(str(login.id))
+ self._abort_if_unique_id_mismatch()
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_API_KEY: login.apiToken},
+ )
+ elif user_input[SECTION_REAUTH_API_KEY].get(CONF_API_KEY):
+ errors, user = await self.validate_api_key(
+ {
+ **reauth_entry.data,
+ **user_input[SECTION_REAUTH_API_KEY],
+ }
+ )
+ if not errors and user is not None:
+ return self.async_update_reload_and_abort(
+ reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
+ )
+ else:
+ errors["base"] = "invalid_credentials"
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=STEP_REAUTH_DATA_SCHEMA,
+ suggested_values={
+ CONF_USERNAME: (
+ user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME)
+ if user_input
+ else None,
+ )
+ },
+ ),
+ description_placeholders={
+ CONF_NAME: reauth_entry.title,
+ "habiticans": HABITICANS_URL,
+ },
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ errors: dict[str, str] = {}
+ reconf_entry = self._get_reconfigure_entry()
+ suggested_values = {
+ CONF_API_KEY: reconf_entry.data[CONF_API_KEY],
+ SECTION_DANGER_ZONE: {
+ CONF_URL: reconf_entry.data[CONF_URL],
+ CONF_VERIFY_SSL: reconf_entry.data.get(CONF_VERIFY_SSL, True),
+ },
+ }
+
+ if user_input:
+ errors, user = await self.validate_api_key(
+ {
+ **reconf_entry.data,
+ **user_input,
+ **user_input[SECTION_DANGER_ZONE],
+ }
+ )
+ if not errors and user is not None:
+ return self.async_update_reload_and_abort(
+ reconf_entry,
+ data_updates={
+ CONF_API_KEY: user_input[CONF_API_KEY],
+ **user_input[SECTION_DANGER_ZONE],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=STEP_RECONF_DATA_SCHEMA,
+ suggested_values=user_input or suggested_values,
+ ),
+ errors=errors,
+ description_placeholders={
+ "site_data": SITE_DATA_URL,
+ "habiticans": HABITICANS_URL,
+ },
+ )
+
+ async def validate_login(
+ self, user_input: Mapping[str, Any]
+ ) -> tuple[dict[str, str], LoginData | None, UserData | None]:
+ """Validate login with login credentials."""
+ errors: dict[str, str] = {}
+ session = async_get_clientsession(
+ self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
+ )
+ api = Habitica(session=session, x_client=X_CLIENT)
+ try:
+ login = await api.login(
+ username=user_input[CONF_USERNAME],
+ password=user_input[CONF_PASSWORD],
+ )
+ user = await api.get_user(user_fields="profile")
+
+ except NotAuthorizedError:
+ errors["base"] = "invalid_auth"
+ except (HabiticaException, ClientError):
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return errors, login.data, user.data
+
+ return errors, None, None
+
+ async def validate_api_key(
+ self, user_input: Mapping[str, Any]
+ ) -> tuple[dict[str, str], UserData | None]:
+ """Validate authentication with api key."""
+ errors: dict[str, str] = {}
+ session = async_get_clientsession(
+ self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
+ )
+ api = Habitica(
+ session=session,
+ x_client=X_CLIENT,
+ api_user=user_input[CONF_API_USER],
+ api_key=user_input[CONF_API_KEY],
+ url=user_input.get(CONF_URL, DEFAULT_URL),
+ )
+ try:
+ user = await api.get_user(user_fields="profile")
+ except NotAuthorizedError:
+ errors["base"] = "invalid_auth"
+ except (HabiticaException, ClientError):
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return errors, user.data
+
+ return errors, None
diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py
index 42d64ca7d3f..5eb616142e5 100644
--- a/homeassistant/components/habitica/const.py
+++ b/homeassistant/components/habitica/const.py
@@ -1,6 +1,6 @@
"""Constants for the habitica integration."""
-from homeassistant.const import CONF_PATH
+from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__
CONF_API_USER = "api_user"
@@ -31,6 +31,11 @@ ATTR_TASK = "task"
ATTR_DIRECTION = "direction"
ATTR_TARGET = "target"
ATTR_ITEM = "item"
+ATTR_TYPE = "type"
+ATTR_PRIORITY = "priority"
+ATTR_TAG = "tag"
+ATTR_KEYWORD = "keyword"
+
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
SERVICE_ACCEPT_QUEST = "accept_quest"
@@ -38,15 +43,17 @@ SERVICE_CANCEL_QUEST = "cancel_quest"
SERVICE_ABORT_QUEST = "abort_quest"
SERVICE_REJECT_QUEST = "reject_quest"
SERVICE_LEAVE_QUEST = "leave_quest"
+SERVICE_GET_TASKS = "get_tasks"
+
SERVICE_SCORE_HABIT = "score_habit"
SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
-WARRIOR = "warrior"
-ROGUE = "rogue"
-HEALER = "healer"
-MAGE = "wizard"
-
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
+X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
+
+SECTION_REAUTH_LOGIN = "reauth_login"
+SECTION_REAUTH_API_KEY = "reauth_api_key"
+SECTION_DANGER_ZONE = "danger_zone"
diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py
index f9ffb1b53bd..f97b98410bb 100644
--- a/homeassistant/components/habitica/coordinator.py
+++ b/homeassistant/components/habitica/coordinator.py
@@ -5,16 +5,31 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
-from http import HTTPStatus
+from io import BytesIO
import logging
from typing import Any
-from aiohttp import ClientResponseError
-from habitipy.aio import HabitipyAsync
+from aiohttp import ClientError
+from habiticalib import (
+ ContentData,
+ Habitica,
+ HabiticaException,
+ NotAuthorizedError,
+ TaskData,
+ TaskFilter,
+ TooManyRequestsError,
+ UserData,
+ UserStyles,
+)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryNotReady,
+ HomeAssistantError,
+)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -25,10 +40,10 @@ _LOGGER = logging.getLogger(__name__)
@dataclass
class HabiticaData:
- """Coordinator data class."""
+ """Habitica data."""
- user: dict[str, Any]
- tasks: list[dict]
+ user: UserData
+ tasks: list[TaskData]
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
@@ -36,7 +51,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
config_entry: ConfigEntry
- def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
+ def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
"""Initialize the Habitica data coordinator."""
super().__init__(
hass,
@@ -50,25 +65,71 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
immediate=False,
),
)
- self.api = habitipy
- self.content: dict[str, Any] = {}
+ self.habitica = habitica
+ self.content: ContentData
+
+ async def _async_setup(self) -> None:
+ """Set up Habitica integration."""
+
+ try:
+ user = await self.habitica.get_user()
+ self.content = (
+ await self.habitica.get_content(user.data.preferences.language)
+ ).data
+ except NotAuthorizedError as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="authentication_failed",
+ ) from e
+ except TooManyRequestsError as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except HabiticaException as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
+ ) from e
+
+ if not self.config_entry.data.get(CONF_NAME):
+ self.hass.config_entries.async_update_entry(
+ self.config_entry,
+ data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
+ )
async def _async_update_data(self) -> HabiticaData:
try:
- user_response = await self.api.user.get()
- tasks_response = await self.api.tasks.user.get()
- tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
- if not self.content:
- self.content = await self.api.content.get(
- language=user_response["preferences"]["language"]
- )
- except ClientResponseError as error:
- if error.status == HTTPStatus.TOO_MANY_REQUESTS:
- _LOGGER.debug("Rate limit exceeded, will try again later")
- return self.data
- raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
-
- return HabiticaData(user=user_response, tasks=tasks_response)
+ user = (await self.habitica.get_user()).data
+ tasks = (await self.habitica.get_tasks()).data
+ completed_todos = (
+ await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS)
+ ).data
+ except TooManyRequestsError:
+ _LOGGER.debug("Rate limit exceeded, will try again later")
+ return self.data
+ except HabiticaException as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
+ ) from e
+ else:
+ return HabiticaData(user=user, tasks=tasks + completed_todos)
async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
@@ -77,15 +138,33 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
try:
await func(self)
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+ translation_placeholders={"reason": e.error.message},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
) from e
else:
await self.async_request_refresh()
+
+ async def generate_avatar(self, user_styles: UserStyles) -> bytes:
+ """Generate Avatar."""
+
+ avatar = BytesIO()
+ await self.habitica.generate_avatar(
+ fp=avatar, user_styles=user_styles, fmt="PNG"
+ )
+
+ return avatar.getvalue()
diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py
index bca79946503..abfa0f35c4b 100644
--- a/homeassistant/components/habitica/diagnostics.py
+++ b/homeassistant/components/habitica/diagnostics.py
@@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- habitica_data = await config_entry.runtime_data.api.user.anonymized.get()
+ habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized()
return {
"config_entry_data": {
CONF_URL: config_entry.data[CONF_URL],
CONF_API_USER: config_entry.data[CONF_API_USER],
},
- "habitica_data": habitica_data,
+ "habitica_data": habitica_data.to_dict()["data"],
}
diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json
index d4ca5dba10d..6ae6ebd728b 100644
--- a/homeassistant/components/habitica/icons.json
+++ b/homeassistant/components/habitica/icons.json
@@ -121,12 +121,6 @@
"rogue": "mdi:ninja"
}
},
- "todos": {
- "default": "mdi:checkbox-outline"
- },
- "dailys": {
- "default": "mdi:calendar-month"
- },
"habits": {
"default": "mdi:contrast-box"
},
@@ -144,6 +138,27 @@
},
"constitution": {
"default": "mdi:run-fast"
+ },
+ "food_total": {
+ "default": "mdi:candy",
+ "state": {
+ "0": "mdi:candy-off"
+ }
+ },
+ "eggs_total": {
+ "default": "mdi:egg",
+ "state": {
+ "0": "mdi:egg-off"
+ }
+ },
+ "hatching_potions_total": {
+ "default": "mdi:flask-round-bottom"
+ },
+ "saddle": {
+ "default": "mdi:horse"
+ },
+ "quest_scrolls": {
+ "default": "mdi:script-text-outline"
}
},
"switch": {
@@ -196,6 +211,12 @@
},
"transformation": {
"service": "mdi:flask-round-bottom"
+ },
+ "get_tasks": {
+ "service": "mdi:calendar-export",
+ "sections": {
+ "filter": "mdi:calendar-filter"
+ }
}
}
}
diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py
new file mode 100644
index 00000000000..f1dbbc64d41
--- /dev/null
+++ b/homeassistant/components/habitica/image.py
@@ -0,0 +1,78 @@
+"""Image platform for Habitica integration."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from enum import StrEnum
+
+from habiticalib import UserStyles
+
+from homeassistant.components.image import ImageEntity, ImageEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import dt as dt_util
+
+from . import HabiticaConfigEntry
+from .coordinator import HabiticaDataUpdateCoordinator
+from .entity import HabiticaBase
+
+PARALLEL_UPDATES = 1
+
+
+class HabiticaImageEntity(StrEnum):
+ """Image entities."""
+
+ AVATAR = "avatar"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HabiticaConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the habitica image platform."""
+
+ coordinator = config_entry.runtime_data
+
+ async_add_entities([HabiticaImage(hass, coordinator)])
+
+
+class HabiticaImage(HabiticaBase, ImageEntity):
+ """A Habitica image entity."""
+
+ entity_description = ImageEntityDescription(
+ key=HabiticaImageEntity.AVATAR,
+ translation_key=HabiticaImageEntity.AVATAR,
+ )
+ _attr_content_type = "image/png"
+ _current_appearance: UserStyles | None = None
+ _cache: bytes | None = None
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ coordinator: HabiticaDataUpdateCoordinator,
+ ) -> None:
+ """Initialize the image entity."""
+ super().__init__(coordinator, self.entity_description)
+ ImageEntity.__init__(self, hass)
+ self._attr_image_last_updated = dt_util.utcnow()
+
+ def _handle_coordinator_update(self) -> None:
+ """Check if equipped gear and other things have changed since last avatar image generation."""
+ new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user))
+
+ if self._current_appearance != new_appearance:
+ self._current_appearance = new_appearance
+ self._attr_image_last_updated = dt_util.utcnow()
+ self._cache = None
+
+ return super()._handle_coordinator_update()
+
+ async def async_image(self) -> bytes | None:
+ """Return cached bytes, otherwise generate new avatar."""
+ if not self._cache and self._current_appearance:
+ self._cache = await self.coordinator.generate_avatar(
+ self._current_appearance
+ )
+ return self._cache
diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json
index a01697c3945..6ace6d45509 100644
--- a/homeassistant/components/habitica/manifest.json
+++ b/homeassistant/components/habitica/manifest.json
@@ -5,6 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
- "loggers": ["habitipy", "plumbum"],
- "requirements": ["habitipy==0.3.3"]
+ "loggers": ["habiticalib"],
+ "requirements": ["habiticalib==0.3.4"]
}
diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml
index 9d505b85b8c..9eadba496f2 100644
--- a/homeassistant/components/habitica/quality_scale.yaml
+++ b/homeassistant/components/habitica/quality_scale.yaml
@@ -4,11 +4,9 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
- config-flow-test-coverage:
- status: todo
- comment: test already_configured, tests should finish with create_entry or abort, assert unique_id
+ config-flow-test-coverage: done
config-flow: done
- dependency-transparency: todo
+ dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
@@ -33,8 +31,8 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates: todo
- reauthentication-flow: todo
+ parallel-updates: done
+ reauthentication-flow: done
test-coverage: done
# Gold
@@ -66,11 +64,9 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
- exception-translations:
- status: todo
- comment: translations for UpdateFailed missing
+ exception-translations: done
icon-translations: done
- reconfiguration-flow: todo
+ reconfiguration-flow: done
repair-issues:
status: done
comment: Used to inform of deprecated entities and actions.
@@ -79,6 +75,6 @@ rules:
comment: Not applicable. Only one device per config entry. Removed together with the config entry.
# Platinum
- async-dependency: todo
+ async-dependency: done
inject-websession: done
- strict-typing: todo
+ strict-typing: done
diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py
index bead15d109b..57c391f5c12 100644
--- a/homeassistant/components/habitica/sensor.py
+++ b/homeassistant/components/habitica/sensor.py
@@ -3,11 +3,23 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
from enum import StrEnum
import logging
-from typing import TYPE_CHECKING, Any
+from typing import Any
+from habiticalib import (
+ ContentData,
+ HabiticaClass,
+ TaskData,
+ TaskType,
+ UserData,
+ deserialize_task,
+ ha,
+)
+
+from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
@@ -25,33 +37,44 @@ from homeassistant.helpers.issue_registry import (
from homeassistant.helpers.typing import StateType
from .const import ASSETS_URL, DOMAIN
+from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
-from .util import entity_used_in, get_attribute_points, get_attributes_total
+from .util import get_attribute_points, get_attributes_total, inventory_list
_LOGGER = logging.getLogger(__name__)
+SVG_CLASS = {
+ HabiticaClass.WARRIOR: ha.WARRIOR,
+ HabiticaClass.ROGUE: ha.ROGUE,
+ HabiticaClass.MAGE: ha.WIZARD,
+ HabiticaClass.HEALER: ha.HEALER,
+}
+
+
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
-class HabitipySensorEntityDescription(SensorEntityDescription):
- """Habitipy Sensor Description."""
+class HabiticaSensorEntityDescription(SensorEntityDescription):
+ """Habitica Sensor Description."""
- value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType]
- attributes_fn: (
- Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None
- ) = None
+ value_fn: Callable[[UserData, ContentData], StateType]
+ attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
+ None
+ )
entity_picture: str | None = None
@dataclass(kw_only=True, frozen=True)
-class HabitipyTaskSensorEntityDescription(SensorEntityDescription):
- """Habitipy Task Sensor Description."""
+class HabiticaTaskSensorEntityDescription(SensorEntityDescription):
+ """Habitica Task Sensor Description."""
- value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]]
+ value_fn: Callable[[list[TaskData]], list[TaskData]]
-class HabitipySensorEntity(StrEnum):
- """Habitipy Entities."""
+class HabiticaSensorEntity(StrEnum):
+ """Habitica Entities."""
DISPLAY_NAME = "display_name"
HEALTH = "health"
@@ -64,8 +87,6 @@ class HabitipySensorEntity(StrEnum):
GOLD = "gold"
CLASS = "class"
HABITS = "habits"
- DAILIES = "dailys"
- TODOS = "todos"
REWARDS = "rewards"
GEMS = "gems"
TRINKETS = "trinkets"
@@ -73,117 +94,161 @@ class HabitipySensorEntity(StrEnum):
INTELLIGENCE = "intelligence"
CONSTITUTION = "constitution"
PERCEPTION = "perception"
+ EGGS_TOTAL = "eggs_total"
+ HATCHING_POTIONS_TOTAL = "hatching_potions_total"
+ FOOD_TOTAL = "food_total"
+ SADDLE = "saddle"
+ QUEST_SCROLLS = "quest_scrolls"
-SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.DISPLAY_NAME,
- translation_key=HabitipySensorEntity.DISPLAY_NAME,
- value_fn=lambda user, _: user.get("profile", {}).get("name"),
+SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.DISPLAY_NAME,
+ translation_key=HabiticaSensorEntity.DISPLAY_NAME,
+ value_fn=lambda user, _: user.profile.name,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.HEALTH,
- translation_key=HabitipySensorEntity.HEALTH,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.HEALTH,
+ translation_key=HabiticaSensorEntity.HEALTH,
suggested_display_precision=0,
- value_fn=lambda user, _: user.get("stats", {}).get("hp"),
+ value_fn=lambda user, _: user.stats.hp,
+ entity_picture=ha.HP,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.HEALTH_MAX,
- translation_key=HabitipySensorEntity.HEALTH_MAX,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.HEALTH_MAX,
+ translation_key=HabiticaSensorEntity.HEALTH_MAX,
entity_registry_enabled_default=False,
- value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
+ value_fn=lambda user, _: 50,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.MANA,
- translation_key=HabitipySensorEntity.MANA,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.MANA,
+ translation_key=HabiticaSensorEntity.MANA,
suggested_display_precision=0,
- value_fn=lambda user, _: user.get("stats", {}).get("mp"),
+ value_fn=lambda user, _: user.stats.mp,
+ entity_picture=ha.MP,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.MANA_MAX,
- translation_key=HabitipySensorEntity.MANA_MAX,
- value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.MANA_MAX,
+ translation_key=HabiticaSensorEntity.MANA_MAX,
+ value_fn=lambda user, _: user.stats.maxMP,
+ entity_picture=ha.MP,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.EXPERIENCE,
- translation_key=HabitipySensorEntity.EXPERIENCE,
- value_fn=lambda user, _: user.get("stats", {}).get("exp"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.EXPERIENCE,
+ translation_key=HabiticaSensorEntity.EXPERIENCE,
+ value_fn=lambda user, _: user.stats.exp,
+ entity_picture=ha.XP,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.EXPERIENCE_MAX,
- translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
- value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.EXPERIENCE_MAX,
+ translation_key=HabiticaSensorEntity.EXPERIENCE_MAX,
+ value_fn=lambda user, _: user.stats.toNextLevel,
+ entity_picture=ha.XP,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.LEVEL,
- translation_key=HabitipySensorEntity.LEVEL,
- value_fn=lambda user, _: user.get("stats", {}).get("lvl"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.LEVEL,
+ translation_key=HabiticaSensorEntity.LEVEL,
+ value_fn=lambda user, _: user.stats.lvl,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.GOLD,
- translation_key=HabitipySensorEntity.GOLD,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.GOLD,
+ translation_key=HabiticaSensorEntity.GOLD,
suggested_display_precision=2,
- value_fn=lambda user, _: user.get("stats", {}).get("gp"),
+ value_fn=lambda user, _: user.stats.gp,
+ entity_picture=ha.GP,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.CLASS,
- translation_key=HabitipySensorEntity.CLASS,
- value_fn=lambda user, _: user.get("stats", {}).get("class"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.CLASS,
+ translation_key=HabiticaSensorEntity.CLASS,
+ value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None,
device_class=SensorDeviceClass.ENUM,
- options=["warrior", "healer", "wizard", "rogue"],
+ options=[item.value for item in HabiticaClass],
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.GEMS,
- translation_key=HabitipySensorEntity.GEMS,
- value_fn=lambda user, _: user.get("balance", 0) * 4,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.GEMS,
+ translation_key=HabiticaSensorEntity.GEMS,
+ value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4),
suggested_display_precision=0,
entity_picture="shop_gem.png",
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.TRINKETS,
- translation_key=HabitipySensorEntity.TRINKETS,
- value_fn=(
- lambda user, _: user.get("purchased", {})
- .get("plan", {})
- .get("consecutive", {})
- .get("trinkets", 0)
- ),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.TRINKETS,
+ translation_key=HabiticaSensorEntity.TRINKETS,
+ value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets,
suggested_display_precision=0,
native_unit_of_measurement="⧖",
entity_picture="notif_subscriber_reward.png",
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.STRENGTH,
- translation_key=HabitipySensorEntity.STRENGTH,
- value_fn=lambda user, content: get_attributes_total(user, content, "str"),
- attributes_fn=lambda user, content: get_attribute_points(user, content, "str"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.STRENGTH,
+ translation_key=HabiticaSensorEntity.STRENGTH,
+ value_fn=lambda user, content: get_attributes_total(user, content, "Str"),
+ attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"),
suggested_display_precision=0,
native_unit_of_measurement="STR",
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.INTELLIGENCE,
- translation_key=HabitipySensorEntity.INTELLIGENCE,
- value_fn=lambda user, content: get_attributes_total(user, content, "int"),
- attributes_fn=lambda user, content: get_attribute_points(user, content, "int"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.INTELLIGENCE,
+ translation_key=HabiticaSensorEntity.INTELLIGENCE,
+ value_fn=lambda user, content: get_attributes_total(user, content, "Int"),
+ attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"),
suggested_display_precision=0,
native_unit_of_measurement="INT",
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.PERCEPTION,
- translation_key=HabitipySensorEntity.PERCEPTION,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.PERCEPTION,
+ translation_key=HabiticaSensorEntity.PERCEPTION,
value_fn=lambda user, content: get_attributes_total(user, content, "per"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "per"),
suggested_display_precision=0,
native_unit_of_measurement="PER",
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.CONSTITUTION,
- translation_key=HabitipySensorEntity.CONSTITUTION,
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.CONSTITUTION,
+ translation_key=HabiticaSensorEntity.CONSTITUTION,
value_fn=lambda user, content: get_attributes_total(user, content, "con"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "con"),
suggested_display_precision=0,
native_unit_of_measurement="CON",
),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.EGGS_TOTAL,
+ translation_key=HabiticaSensorEntity.EGGS_TOTAL,
+ value_fn=lambda user, _: sum(n for n in user.items.eggs.values()),
+ entity_picture="Pet_Egg_Egg.png",
+ attributes_fn=lambda user, content: inventory_list(user, content, "eggs"),
+ ),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL,
+ translation_key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL,
+ value_fn=lambda user, _: sum(n for n in user.items.hatchingPotions.values()),
+ entity_picture="Pet_HatchingPotion_RoyalPurple.png",
+ attributes_fn=(
+ lambda user, content: inventory_list(user, content, "hatchingPotions")
+ ),
+ ),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.FOOD_TOTAL,
+ translation_key=HabiticaSensorEntity.FOOD_TOTAL,
+ value_fn=(
+ lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle")
+ ),
+ entity_picture=ha.FOOD,
+ attributes_fn=lambda user, content: inventory_list(user, content, "food"),
+ ),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.SADDLE,
+ translation_key=HabiticaSensorEntity.SADDLE,
+ value_fn=lambda user, _: user.items.food.get("Saddle", 0),
+ entity_picture="Pet_Food_Saddle.png",
+ ),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.QUEST_SCROLLS,
+ translation_key=HabiticaSensorEntity.QUEST_SCROLLS,
+ value_fn=(lambda user, _: sum(n for n in user.items.quests.values())),
+ entity_picture="inventory_quest_scroll_dustbunnies.png",
+ attributes_fn=lambda user, content: inventory_list(user, content, "quests"),
+ ),
)
@@ -203,7 +268,7 @@ TASKS_MAP = {
"yester_daily": "yesterDaily",
"completed": "completed",
"collapse_checklist": "collapseChecklist",
- "type": "type",
+ "type": "Type",
"notes": "notes",
"tags": "tags",
"value": "value",
@@ -217,34 +282,27 @@ TASKS_MAP = {
}
-TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
- HabitipyTaskSensorEntityDescription(
- key=HabitipySensorEntity.HABITS,
- translation_key=HabitipySensorEntity.HABITS,
- value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"],
+TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = (
+ HabiticaTaskSensorEntityDescription(
+ key=HabiticaSensorEntity.HABITS,
+ translation_key=HabiticaSensorEntity.HABITS,
+ value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT],
),
- HabitipyTaskSensorEntityDescription(
- key=HabitipySensorEntity.DAILIES,
- translation_key=HabitipySensorEntity.DAILIES,
- value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"],
- entity_registry_enabled_default=False,
- ),
- HabitipyTaskSensorEntityDescription(
- key=HabitipySensorEntity.TODOS,
- translation_key=HabitipySensorEntity.TODOS,
- value_fn=lambda tasks: [
- r for r in tasks if r.get("type") == "todo" and not r.get("completed")
- ],
- entity_registry_enabled_default=False,
- ),
- HabitipyTaskSensorEntityDescription(
- key=HabitipySensorEntity.REWARDS,
- translation_key=HabitipySensorEntity.REWARDS,
- value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"],
+ HabiticaTaskSensorEntityDescription(
+ key=HabiticaSensorEntity.REWARDS,
+ translation_key=HabiticaSensorEntity.REWARDS,
+ value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD],
),
)
+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
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
@@ -253,21 +311,65 @@ async def async_setup_entry(
"""Set up the habitica sensors."""
coordinator = config_entry.runtime_data
+ ent_reg = er.async_get(hass)
+ entities: list[SensorEntity] = []
+ description: SensorEntityDescription
+
+ def add_deprecated_entity(
+ description: SensorEntityDescription,
+ entity_cls: Callable[
+ [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity
+ ],
+ ) -> None:
+ """Add deprecated entities."""
+ if entity_id := ent_reg.async_get_entity_id(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ f"{config_entry.unique_id}_{description.key}",
+ ):
+ entity_entry = ent_reg.async_get(entity_id)
+ if entity_entry and entity_entry.disabled:
+ ent_reg.async_remove(entity_id)
+ async_delete_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_entity_{description.key}",
+ )
+ elif entity_entry:
+ entities.append(entity_cls(coordinator, description))
+ if entity_used_in(hass, entity_id):
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_entity_{description.key}",
+ breaks_in_ha_version="2025.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_entity",
+ translation_placeholders={
+ "name": str(
+ entity_entry.name or entity_entry.original_name
+ ),
+ "entity": entity_id,
+ },
+ )
+
+ for description in SENSOR_DESCRIPTIONS:
+ if description.key is HabiticaSensorEntity.HEALTH_MAX:
+ add_deprecated_entity(description, HabiticaSensor)
+ else:
+ entities.append(HabiticaSensor(coordinator, description))
+
+ for description in TASK_SENSOR_DESCRIPTION:
+ add_deprecated_entity(description, HabiticaTaskSensor)
- entities: list[SensorEntity] = [
- HabitipySensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
- ]
- entities.extend(
- HabitipyTaskSensor(coordinator, description)
- for description in TASK_SENSOR_DESCRIPTION
- )
async_add_entities(entities, True)
-class HabitipySensor(HabiticaBase, SensorEntity):
+class HabiticaSensor(HabiticaBase, SensorEntity):
"""A generic Habitica sensor."""
- entity_description: HabitipySensorEntityDescription
+ entity_description: HabiticaSensorEntityDescription
@property
def native_value(self) -> StateType:
@@ -287,15 +389,25 @@ class HabitipySensor(HabiticaBase, SensorEntity):
@property
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend, if any."""
+ if self.entity_description.key is HabiticaSensorEntity.CLASS and (
+ _class := self.coordinator.data.user.stats.Class
+ ):
+ return SVG_CLASS[_class]
+
if entity_picture := self.entity_description.entity_picture:
- return f"{ASSETS_URL}{entity_picture}"
+ return (
+ entity_picture
+ if entity_picture.startswith("data:image")
+ else f"{ASSETS_URL}{entity_picture}"
+ )
+
return None
-class HabitipyTaskSensor(HabiticaBase, SensorEntity):
+class HabiticaTaskSensor(HabiticaBase, SensorEntity):
"""A Habitica task sensor."""
- entity_description: HabitipyTaskSensorEntityDescription
+ entity_description: HabiticaTaskSensorEntityDescription
@property
def native_value(self) -> StateType:
@@ -309,47 +421,12 @@ class HabitipyTaskSensor(HabiticaBase, SensorEntity):
attrs = {}
# Map tasks to TASKS_MAP
- for received_task in self.entity_description.value_fn(
- self.coordinator.data.tasks
- ):
+ for task_data in self.entity_description.value_fn(self.coordinator.data.tasks):
+ received_task = deserialize_task(asdict(task_data))
task_id = received_task[TASKS_MAP_ID]
task = {}
for map_key, map_value in TASKS_MAP.items():
if value := received_task.get(map_value):
task[map_key] = value
- attrs[task_id] = task
+ attrs[str(task_id)] = task
return attrs
-
- async def async_added_to_hass(self) -> None:
- """Raise issue when entity is registered and was not disabled."""
- if TYPE_CHECKING:
- assert self.unique_id
- if entity_id := er.async_get(self.hass).async_get_entity_id(
- SENSOR_DOMAIN, DOMAIN, self.unique_id
- ):
- if (
- self.enabled
- and self.entity_description.key
- in (HabitipySensorEntity.TODOS, HabitipySensorEntity.DAILIES)
- and entity_used_in(self.hass, entity_id)
- ):
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_task_entity_{self.entity_description.key}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_task_entity",
- translation_placeholders={
- "task_name": str(self.name),
- "entity": entity_id,
- },
- )
- else:
- async_delete_issue(
- self.hass,
- DOMAIN,
- f"deprecated_task_entity_{self.entity_description.key}",
- )
- await super().async_added_to_hass()
diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py
index 7f2d66e4690..a28aada85fa 100644
--- a/homeassistant/components/habitica/services.py
+++ b/homeassistant/components/habitica/services.py
@@ -2,11 +2,22 @@
from __future__ import annotations
-from http import HTTPStatus
+from dataclasses import asdict
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
-from aiohttp import ClientResponseError
+from aiohttp import ClientError
+from habiticalib import (
+ Direction,
+ HabiticaException,
+ NotAuthorizedError,
+ NotFoundError,
+ Skill,
+ TaskData,
+ TaskPriority,
+ TaskType,
+ TooManyRequestsError,
+)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
@@ -28,10 +39,14 @@ from .const import (
ATTR_DATA,
ATTR_DIRECTION,
ATTR_ITEM,
+ ATTR_KEYWORD,
ATTR_PATH,
+ ATTR_PRIORITY,
ATTR_SKILL,
+ ATTR_TAG,
ATTR_TARGET,
ATTR_TASK,
+ ATTR_TYPE,
DOMAIN,
EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST,
@@ -39,6 +54,7 @@ from .const import (
SERVICE_API_CALL,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
+ SERVICE_GET_TASKS,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_SCORE_HABIT,
@@ -88,6 +104,40 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
}
)
+SERVICE_GET_TASKS_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
+ vol.Optional(ATTR_TYPE): vol.All(
+ cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))]
+ ),
+ vol.Optional(ATTR_PRIORITY): vol.All(
+ cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskPriority}))]
+ ),
+ vol.Optional(ATTR_TASK): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_KEYWORD): cv.string,
+ }
+)
+
+SKILL_MAP = {
+ "pickpocket": Skill.PICKPOCKET,
+ "backstab": Skill.BACKSTAB,
+ "smash": Skill.BRUTAL_SMASH,
+ "fireball": Skill.BURST_OF_FLAMES,
+}
+COST_MAP = {
+ "pickpocket": "10 MP",
+ "backstab": "15 MP",
+ "smash": "10 MP",
+ "fireball": "10 MP",
+}
+ITEMID_MAP = {
+ "snowball": Skill.SNOWBALL,
+ "spooky_sparkles": Skill.SPOOKY_SPARKLES,
+ "seafoam": Skill.SEAFOAM,
+ "shiny_seed": Skill.SHINY_SEED,
+}
+
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@@ -123,12 +173,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH]
- entries = hass.config_entries.async_entries(DOMAIN)
+ entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN)
api = None
for entry in entries:
if entry.data[CONF_NAME] == name:
- api = entry.runtime_data.api
+ api = await entry.runtime_data.habitica.habitipy()
break
if api is None:
_LOGGER.error("API_CALL: User '%s' not configured", name)
@@ -151,18 +201,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Skill action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
- skill = {
- "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},
- "backstab": {"spellId": "backStab", "cost": "15 MP"},
- "smash": {"spellId": "smash", "cost": "10 MP"},
- "fireball": {"spellId": "fireball", "cost": "10 MP"},
- }
+
+ skill = SKILL_MAP[call.data[ATTR_SKILL]]
+ cost = COST_MAP[call.data[ATTR_SKILL]]
+
try:
task_id = next(
- task["id"]
+ task.id
for task in coordinator.data.tasks
- if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
- or call.data[ATTR_TASK] == task["text"]
+ if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
)
except StopIteration as e:
raise ServiceValidationError(
@@ -172,75 +219,93 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
) from e
try:
- response: dict[str, Any] = await coordinator.api.user.class_.cast[
- skill[call.data[ATTR_SKILL]]["spellId"]
- ].post(targetId=task_id)
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- if e.status == HTTPStatus.UNAUTHORIZED:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="not_enough_mana",
- translation_placeholders={
- "cost": skill[call.data[ATTR_SKILL]]["cost"],
- "mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP",
- },
- ) from e
- if e.status == HTTPStatus.NOT_FOUND:
- # could also be task not found, but the task is looked up
- # before the request, so most likely wrong skill selected
- # or the skill hasn't been unlocked yet.
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="skill_not_found",
- translation_placeholders={"skill": call.data[ATTR_SKILL]},
- ) from e
+ response = await coordinator.habitica.cast_skill(skill, task_id)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except NotAuthorizedError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="not_enough_mana",
+ translation_placeholders={
+ "cost": cost,
+ "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP",
+ },
+ ) from e
+ except NotFoundError as e:
+ # could also be task not found, but the task is looked up
+ # before the request, so most likely wrong skill selected
+ # or the skill hasn't been unlocked yet.
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="skill_not_found",
+ translation_placeholders={"skill": call.data[ATTR_SKILL]},
+ ) from e
+ except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
) from e
else:
await coordinator.async_request_refresh()
- return response
+ return asdict(response.data)
async def manage_quests(call: ServiceCall) -> ServiceResponse:
"""Accept, reject, start, leave or cancel quests."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
- COMMAND_MAP = {
- SERVICE_ABORT_QUEST: "abort",
- SERVICE_ACCEPT_QUEST: "accept",
- SERVICE_CANCEL_QUEST: "cancel",
- SERVICE_LEAVE_QUEST: "leave",
- SERVICE_REJECT_QUEST: "reject",
- SERVICE_START_QUEST: "force-start",
+ FUNC_MAP = {
+ SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
+ SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
+ SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
+ SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest,
+ SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest,
+ SERVICE_START_QUEST: coordinator.habitica.start_quest,
}
+
+ func = FUNC_MAP[call.service]
+
try:
- return await coordinator.api.groups.party.quests[
- COMMAND_MAP[call.service]
- ].post()
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- if e.status == HTTPStatus.UNAUTHORIZED:
- raise ServiceValidationError(
- translation_domain=DOMAIN, translation_key="quest_action_unallowed"
- ) from e
- if e.status == HTTPStatus.NOT_FOUND:
- raise ServiceValidationError(
- translation_domain=DOMAIN, translation_key="quest_not_found"
- ) from e
+ response = await func()
+ except TooManyRequestsError as e:
raise HomeAssistantError(
- translation_domain=DOMAIN, translation_key="service_call_exception"
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
) from e
+ except NotAuthorizedError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="quest_action_unallowed"
+ ) from e
+ except NotFoundError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="quest_not_found"
+ ) from e
+ except HabiticaException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
+ ) from e
+ else:
+ return asdict(response.data)
for service in (
SERVICE_ABORT_QUEST,
@@ -262,12 +327,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Score a task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
+
+ direction = (
+ Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP
+ )
try:
task_id, task_value = next(
- (task["id"], task.get("value"))
+ (task.id, task.value)
for task in coordinator.data.tasks
- if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
- or call.data[ATTR_TASK] == task["text"]
+ if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
)
except StopIteration as e:
raise ServiceValidationError(
@@ -276,81 +344,92 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
) from e
+ if TYPE_CHECKING:
+ assert task_id
try:
- response: dict[str, Any] = (
- await coordinator.api.tasks[task_id]
- .score[call.data.get(ATTR_DIRECTION, "up")]
- .post()
- )
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
+ response = await coordinator.habitica.update_score(task_id, direction)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except NotAuthorizedError as e:
+ if task_value is not None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_gold",
translation_placeholders={
- "gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
- "cost": f"{task_value} GP",
+ "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP",
+ "cost": f"{task_value:.2f} GP",
},
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+ translation_placeholders={"reason": e.error.message},
+ ) from e
+ except HabiticaException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
) from e
else:
await coordinator.async_request_refresh()
- return response
+ return asdict(response.data)
async def transformation(call: ServiceCall) -> ServiceResponse:
"""User a transformation item on a player character."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
- ITEMID_MAP = {
- "snowball": {"itemId": "snowball"},
- "spooky_sparkles": {"itemId": "spookySparkles"},
- "seafoam": {"itemId": "seafoam"},
- "shiny_seed": {"itemId": "shinySeed"},
- }
+
+ item = ITEMID_MAP[call.data[ATTR_ITEM]]
# check if target is self
if call.data[ATTR_TARGET] in (
- coordinator.data.user["id"],
- coordinator.data.user["profile"]["name"],
- coordinator.data.user["auth"]["local"]["username"],
+ str(coordinator.data.user.id),
+ coordinator.data.user.profile.name,
+ coordinator.data.user.auth.local.username,
):
- target_id = coordinator.data.user["id"]
+ target_id = coordinator.data.user.id
else:
# check if target is a party member
try:
- party = await coordinator.api.groups.party.members.get()
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- if e.status == HTTPStatus.NOT_FOUND:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="party_not_found",
- ) from e
+ party = await coordinator.habitica.get_group_members(public_fields=True)
+ except NotFoundError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="party_not_found",
+ ) from e
+ except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
) from e
try:
target_id = next(
- member["id"]
- for member in party
- if call.data[ATTR_TARGET].lower()
+ member.id
+ for member in party.data
+ if member.id
+ and call.data[ATTR_TARGET].lower()
in (
- member["id"],
- member["auth"]["local"]["username"].lower(),
- member["profile"]["name"].lower(),
+ str(member.id),
+ str(member.auth.local.username).lower(),
+ str(member.profile.name).lower(),
)
)
except StopIteration as e:
@@ -360,27 +439,79 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
) from e
try:
- response: dict[str, Any] = await coordinator.api.user.class_.cast[
- ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"]
- ].post(targetId=target_id)
- except ClientResponseError as e:
- if e.status == HTTPStatus.TOO_MANY_REQUESTS:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="setup_rate_limit_exception",
- ) from e
- if e.status == HTTPStatus.UNAUTHORIZED:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="item_not_found",
- translation_placeholders={"item": call.data[ATTR_ITEM]},
- ) from e
+ response = await coordinator.habitica.cast_skill(item, target_id)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except NotAuthorizedError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="item_not_found",
+ translation_placeholders={"item": call.data[ATTR_ITEM]},
+ ) from e
+ except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
) from e
else:
- return response
+ return asdict(response.data)
+
+ async def get_tasks(call: ServiceCall) -> ServiceResponse:
+ """Get tasks action."""
+
+ entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
+ coordinator = entry.runtime_data
+ response: list[TaskData] = coordinator.data.tasks
+
+ if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}:
+ response = [task for task in response if task.Type in types]
+
+ if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}:
+ response = [task for task in response if task.priority in priority]
+
+ if tasks := call.data.get(ATTR_TASK):
+ response = [
+ task
+ for task in response
+ if str(task.id) in tasks or task.alias in tasks or task.text in tasks
+ ]
+
+ if tags := call.data.get(ATTR_TAG):
+ tag_ids = {
+ tag.id
+ for tag in coordinator.data.user.tags
+ if (tag.name and tag.name.lower())
+ in (tag.lower() for tag in tags) # Case-insensitive matching
+ and tag.id
+ }
+
+ response = [
+ task
+ for task in response
+ if any(tag_id in task.tags for tag_id in tag_ids if task.tags)
+ ]
+ if keyword := call.data.get(ATTR_KEYWORD):
+ keyword = keyword.lower()
+ response = [
+ task
+ for task in response
+ if (task.text and keyword in task.text.lower())
+ or (task.notes and keyword in task.notes.lower())
+ or any(keyword in item.text.lower() for item in task.checklist)
+ ]
+ result: dict[str, Any] = {"tasks": response}
+ return result
hass.services.async_register(
DOMAIN,
@@ -419,3 +550,10 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
schema=SERVICE_TRANSFORMATION_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_TASKS,
+ get_tasks,
+ schema=SERVICE_GET_TASKS_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml
index a89c935b630..f3095518290 100644
--- a/homeassistant/components/habitica/services.yaml
+++ b/homeassistant/components/habitica/services.yaml
@@ -94,3 +94,49 @@ transformation:
required: true
selector:
text:
+get_tasks:
+ fields:
+ config_entry: *config_entry
+ filter:
+ collapsed: true
+ fields:
+ type:
+ required: false
+ selector:
+ select:
+ options:
+ - "habit"
+ - "daily"
+ - "todo"
+ - "reward"
+ mode: dropdown
+ translation_key: "type"
+ multiple: true
+ sort: true
+ priority:
+ required: false
+ selector:
+ select:
+ options:
+ - "trivial"
+ - "easy"
+ - "medium"
+ - "hard"
+ mode: dropdown
+ translation_key: "priority"
+ multiple: true
+ sort: false
+ task:
+ required: false
+ selector:
+ text:
+ multiple: true
+ tag:
+ required: false
+ selector:
+ text:
+ multiple: true
+ keyword:
+ required: false
+ selector:
+ text:
diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json
index f1b956fe17e..4d353cec40e 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -3,6 +3,7 @@
"todos": "To-Do's",
"dailies": "Dailies",
"config_entry_name": "Select character",
+ "task_name": "Task name",
"unit_tasks": "tasks",
"unit_health_points": "HP",
"unit_mana_points": "MP",
@@ -10,19 +11,23 @@
},
"config": {
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "unknown": "[%key:common::config_flow::error::unknown%]"
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "invalid_credentials": "Input is incomplete. You must provide either your login details or an API token"
},
"step": {
"user": {
"title": "Habitica - Gamify your life",
"menu_options": {
- "login": "Login to Habitica",
- "advanced": "Login to other instances"
+ "login": "Log in to Habitica",
+ "advanced": "Log in to other instances"
},
"description": " Connect your Habitica account to keep track of your adventurer's stats, progress, and manage your to-dos and daily tasks.\n\n[Don't have a Habitica account? Sign up here.]({signup})"
},
@@ -49,9 +54,62 @@
"data_description": {
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
"api_user": "User ID of your Habitica account",
- "api_key": "API Token of the Habitica account"
+ "api_key": "API Token of the Habitica account",
+ "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
},
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to"
+ },
+ "reauth_confirm": {
+ "title": "Re-authorize {name} with Habitica",
+ "description": " It seems your API token for **{name}** has been reset. To re-authorize the integration, you can either log in with your username or email, and password, or directly provide your new API token.",
+ "sections": {
+ "reauth_login": {
+ "name": "Re-authorize via login",
+ "description": "Enter your login details below to re-authorize the Home Assistant integration with Habitica",
+ "data": {
+ "username": "[%key:component::habitica::config::step::login::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::habitica::config::step::login::data_description::username%]",
+ "password": "[%key:component::habitica::config::step::login::data_description::password%]"
+ }
+ },
+ "reauth_api_key": {
+ "description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
+ "name": "Re-authorize via API Token",
+ "data": {
+ "api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
+ }
+ }
+ }
+ },
+ "reconfigure": {
+ "title": "Update Habitica configuration",
+ "description": "\n\nEnter your new API token below. You can find it in Habitica under [**Settings -> Site Data**]({site_data})",
+ "data": {
+ "api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
+ },
+ "sections": {
+ "danger_zone": {
+ "name": "Critical configuration options",
+ "description": "These settings impact core functionality. Modifications are unnecessary if connected to the official Habitica instance and may disrupt the integration. Proceed with caution.",
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "url": "URL of the Habitica instance",
+ "verify_ssl": "[%key:component::habitica::config::step::advanced::data_description::verify_ssl%]"
+ }
+ }
+ }
}
}
},
@@ -134,6 +192,11 @@
"name": "Daily reminders"
}
},
+ "image": {
+ "avatar": {
+ "name": "Avatar"
+ }
+ },
"sensor": {
"display_name": {
"name": "Display name"
@@ -185,14 +248,6 @@
"rogue": "Rogue"
}
},
- "todos": {
- "name": "[%key:component::habitica::common::todos%]",
- "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
- },
- "dailys": {
- "name": "[%key:component::habitica::common::dailies%]",
- "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
- },
"habits": {
"name": "Habits",
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
@@ -280,6 +335,26 @@
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
}
}
+ },
+ "eggs_total": {
+ "name": "Eggs",
+ "unit_of_measurement": "eggs"
+ },
+ "hatching_potions_total": {
+ "name": "Hatching potions",
+ "unit_of_measurement": "potions"
+ },
+ "food_total": {
+ "name": "Pet food",
+ "unit_of_measurement": "foods"
+ },
+ "saddle": {
+ "name": "Saddles",
+ "unit_of_measurement": "saddles"
+ },
+ "quest_scrolls": {
+ "name": "Quest scrolls",
+ "unit_of_measurement": "scrolls"
}
},
"switch": {
@@ -325,13 +400,13 @@
"message": "Unable to create new to-do `{name}` for Habitica, please try again"
},
"setup_rate_limit_exception": {
- "message": "Rate limit exceeded, try again later"
+ "message": "Rate limit exceeded, try again in {retry_after} seconds"
},
"service_call_unallowed": {
"message": "Unable to complete action, the required conditions are not met"
},
"service_call_exception": {
- "message": "Unable to connect to Habitica, try again later"
+ "message": "Unable to connect to Habitica: {reason}"
},
"not_enough_mana": {
"message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}."
@@ -365,12 +440,15 @@
},
"item_not_found": {
"message": "Unable to use {item}, you don't own this item."
+ },
+ "authentication_failed": {
+ "message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
}
},
"issues": {
- "deprecated_task_entity": {
- "title": "The Habitica {task_name} sensor is deprecated",
- "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`."
+ "deprecated_entity": {
+ "title": "The Habitica {name} entity is deprecated",
+ "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
},
"deprecated_api_call": {
"title": "The Habitica action habitica.api_call is deprecated",
@@ -398,7 +476,7 @@
},
"cast_skill": {
"name": "Cast a skill",
- "description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.",
+ "description": "Uses a skill or spell from your Habitica character on a specific task to affect its progress or status.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -409,14 +487,14 @@
"description": "Select the skill or spell you want to cast on the task. Only skills corresponding to your character's class can be used."
},
"task": {
- "name": "Task name",
+ "name": "[%key:component::habitica::common::task_name%]",
"description": "The name (or task ID) of the task you want to target with the skill or spell."
}
}
},
"accept_quest": {
"name": "Accept a quest invitation",
- "description": "Accept a pending invitation to a quest.",
+ "description": "Accepts a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -426,7 +504,7 @@
},
"reject_quest": {
"name": "Reject a quest invitation",
- "description": "Reject a pending invitation to a quest.",
+ "description": "Rejects a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -436,7 +514,7 @@
},
"leave_quest": {
"name": "Leave a quest",
- "description": "Leave the current quest you are participating in.",
+ "description": "Leaves the current quest you are participating in.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -446,7 +524,7 @@
},
"abort_quest": {
"name": "Abort an active quest",
- "description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
+ "description": "Terminates your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -456,7 +534,7 @@
},
"cancel_quest": {
"name": "Cancel a pending quest",
- "description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
+ "description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -466,7 +544,7 @@
},
"start_quest": {
"name": "Force-start a pending quest",
- "description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
+ "description": "Begins the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -476,7 +554,7 @@
},
"score_habit": {
"name": "Track a habit",
- "description": "Increase the positive or negative streak of a habit to track its progress.",
+ "description": "Increases the positive or negative streak of a habit to track its progress.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -494,7 +572,7 @@
},
"score_reward": {
"name": "Buy a reward",
- "description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.",
+ "description": "Buys one of your custom rewards with gold earned by fulfilling tasks.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -508,7 +586,7 @@
},
"transformation": {
"name": "Use a transformation item",
- "description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.",
+ "description": "Uses a transformation item from your Habitica character's inventory on a member of your party or yourself.",
"fields": {
"config_entry": {
"name": "Select character",
@@ -523,6 +601,42 @@
"description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID."
}
}
+ },
+ "get_tasks": {
+ "name": "Get tasks",
+ "description": "Retrieves tasks from your Habitica character.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Choose the Habitica character to retrieve tasks from."
+ },
+ "type": {
+ "name": "Task type",
+ "description": "Filter tasks by type."
+ },
+ "priority": {
+ "name": "Difficulty",
+ "description": "Filter tasks by difficulty."
+ },
+ "task": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "Select tasks by matching their name (or task ID)."
+ },
+ "tag": {
+ "name": "Tag",
+ "description": "Filter tasks that have one or more of the selected tags."
+ },
+ "keyword": {
+ "name": "Keyword",
+ "description": "Filter tasks by keyword, searching across titles, notes, and checklists."
+ }
+ },
+ "sections": {
+ "filter": {
+ "name": "Filter options",
+ "description": "Use the optional filters to narrow the returned tasks."
+ }
+ }
}
},
"selector": {
@@ -541,6 +655,22 @@
"seafoam": "Seafoam",
"shiny_seed": "Shiny seed"
}
+ },
+ "type": {
+ "options": {
+ "daily": "Daily",
+ "habit": "Habit",
+ "todo": "To-do",
+ "reward": "Reward"
+ }
+ },
+ "priority": {
+ "options": {
+ "trivial": "Trivial",
+ "easy": "Easy",
+ "medium": "Medium",
+ "hard": "Hard"
+ }
}
}
}
diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py
index de0cc533050..ddc0db27108 100644
--- a/homeassistant/components/habitica/switch.py
+++ b/homeassistant/components/habitica/switch.py
@@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription):
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
- is_on_fn: Callable[[HabiticaData], bool]
+ is_on_fn: Callable[[HabiticaData], bool | None]
class HabiticaSwitchEntity(StrEnum):
@@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
key=HabiticaSwitchEntity.SLEEP,
translation_key=HabiticaSwitchEntity.SLEEP,
device_class=SwitchDeviceClass.SWITCH,
- turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
- turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
- is_on_fn=lambda data: data.user["preferences"]["sleep"],
+ turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
+ turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
+ is_on_fn=lambda data: data.user.preferences.sleep,
),
)
diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py
index 0ca5f723c45..c1786059300 100644
--- a/homeassistant/components/habitica/todo.py
+++ b/homeassistant/components/habitica/todo.py
@@ -2,11 +2,19 @@
from __future__ import annotations
-import datetime
from enum import StrEnum
+import logging
from typing import TYPE_CHECKING
+from uuid import UUID
-from aiohttp import ClientResponseError
+from aiohttp import ClientError
+from habiticalib import (
+ Direction,
+ HabiticaException,
+ Task,
+ TaskType,
+ TooManyRequestsError,
+)
from homeassistant.components import persistent_notification
from homeassistant.components.todo import (
@@ -16,7 +24,7 @@ from homeassistant.components.todo import (
TodoListEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
@@ -24,9 +32,11 @@ from homeassistant.util import dt as dt_util
from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
-from .types import HabiticaConfigEntry, HabiticaTaskType
+from .types import HabiticaConfigEntry
from .util import next_due_date
+_LOGGER = logging.getLogger(__name__)
+
PARALLEL_UPDATES = 1
@@ -70,8 +80,15 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Delete Habitica tasks."""
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
try:
- await self.coordinator.api.tasks.clearCompletedTodos.post()
- except ClientResponseError as e:
+ await self.coordinator.habitica.delete_completed_todos()
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ _LOGGER.debug(str(e))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="delete_completed_todos_failed",
@@ -79,8 +96,15 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
else:
for task_id in uids:
try:
- await self.coordinator.api.tasks[task_id].delete()
- except ClientResponseError as e:
+ await self.coordinator.habitica.delete_task(UUID(task_id))
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ _LOGGER.debug(str(e))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"delete_{self.entity_description.key}_failed",
@@ -106,9 +130,15 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
pos = 0
try:
- await self.coordinator.api.tasks[uid].move.to[str(pos)].post()
-
- except ClientResponseError as e:
+ await self.coordinator.habitica.reorder_task(UUID(uid), pos)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ _LOGGER.debug(str(e))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"move_{self.entity_description.key}_item_failed",
@@ -118,12 +148,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
# 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))
+ tasks.index(
+ next(task for task in tasks if task.id == UUID(previous_uid))
+ )
+ 1
if previous_uid
else 0
)
- old_pos = tasks.index(next(task for task in tasks if task["id"] == uid))
+ old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
tasks.insert(new_pos, tasks.pop(old_pos))
await self.coordinator.async_request_refresh()
@@ -138,14 +170,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
if TYPE_CHECKING:
assert item.uid
assert current_item
+ assert item.summary
+
+ task = Task(
+ text=item.summary,
+ notes=item.description or "",
+ )
if (
self.entity_description.key is HabiticaTodoList.TODOS
- and item.due is not None
): # Only todos support a due date.
- date = item.due.isoformat()
- else:
- date = None
+ task["date"] = item.due
if (
item.summary != current_item.summary
@@ -153,13 +188,16 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
or item.due != current_item.due
):
try:
- await self.coordinator.api.tasks[item.uid].put(
- text=item.summary,
- notes=item.description or "",
- date=date,
- )
+ await self.coordinator.habitica.update_task(UUID(item.uid), task)
refresh_required = True
- except ClientResponseError as e:
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ _LOGGER.debug(str(e))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"update_{self.entity_description.key}_item_failed",
@@ -172,32 +210,39 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
current_item.status is TodoItemStatus.NEEDS_ACTION
and item.status == TodoItemStatus.COMPLETED
):
- score_result = (
- await self.coordinator.api.tasks[item.uid].score["up"].post()
+ score_result = await self.coordinator.habitica.update_score(
+ UUID(item.uid), Direction.UP
)
refresh_required = True
elif (
current_item.status is TodoItemStatus.COMPLETED
and item.status == TodoItemStatus.NEEDS_ACTION
):
- score_result = (
- await self.coordinator.api.tasks[item.uid].score["down"].post()
+ score_result = await self.coordinator.habitica.update_score(
+ UUID(item.uid), Direction.DOWN
)
refresh_required = True
else:
score_result = None
-
- except ClientResponseError as e:
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ _LOGGER.debug(str(e))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"score_{self.entity_description.key}_item_failed",
translation_placeholders={"name": item.summary or ""},
) from e
- if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)):
+ if score_result and score_result.data.tmp.drop.key:
+ drop = score_result.data.tmp.drop
msg = (
- f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n"
- f"{drop["dialog"]}"
+ f"\n"
+ f"{drop.dialog}"
)
persistent_notification.async_create(
self.hass, message=msg, title="Habitica"
@@ -229,38 +274,43 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
return [
*(
TodoItem(
- uid=task["id"],
- summary=task["text"],
- description=task["notes"],
- due=(
- dt_util.as_local(
- datetime.datetime.fromisoformat(task["date"])
- ).date()
- if task.get("date")
- else None
- ),
+ uid=str(task.id),
+ summary=task.text,
+ description=task.notes,
+ due=dt_util.as_local(task.date).date() if task.date else None,
status=(
TodoItemStatus.NEEDS_ACTION
- if not task["completed"]
+ if not task.completed
else TodoItemStatus.COMPLETED
),
)
for task in self.coordinator.data.tasks
- if task["type"] == HabiticaTaskType.TODO
+ if task.Type is TaskType.TODO
),
]
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a Habitica todo."""
-
+ if TYPE_CHECKING:
+ assert item.summary
+ assert item.description
try:
- await self.coordinator.api.tasks.user.post(
- text=item.summary,
- type=HabiticaTaskType.TODO,
- notes=item.description,
- date=item.due.isoformat() if item.due else None,
+ await self.coordinator.habitica.create_task(
+ Task(
+ text=item.summary,
+ type=TaskType.TODO,
+ notes=item.description,
+ date=item.due,
+ )
)
- except ClientResponseError as e:
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ _LOGGER.debug(str(e))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"create_{self.entity_description.key}_item_failed",
@@ -295,23 +345,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
that have been completed but forgotten to mark as completed before resetting the dailies.
Changes of the date input field in Home Assistant will be ignored.
"""
-
- last_cron = self.coordinator.data.user["lastCron"]
+ if TYPE_CHECKING:
+ assert self.coordinator.data.user.lastCron
return [
*(
TodoItem(
- uid=task["id"],
- summary=task["text"],
- description=task["notes"],
- due=next_due_date(task, last_cron),
+ uid=str(task.id),
+ summary=task.text,
+ description=task.notes,
+ due=next_due_date(task, self.coordinator.data.user.lastCron),
status=(
TodoItemStatus.COMPLETED
- if task["completed"]
+ if task.completed
else TodoItemStatus.NEEDS_ACTION
),
)
for task in self.coordinator.data.tasks
- if task["type"] == HabiticaTaskType.DAILY
+ if task.Type is TaskType.DAILY
)
]
diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py
index b2b4430c490..757c675b045 100644
--- a/homeassistant/components/habitica/util.py
+++ b/homeassistant/components/habitica/util.py
@@ -2,9 +2,10 @@
from __future__ import annotations
+from dataclasses import asdict, fields
import datetime
from math import floor
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from dateutil.rrule import (
DAILY,
@@ -20,94 +21,66 @@ from dateutil.rrule import (
YEARLY,
rrule,
)
+from habiticalib import ContentData, Frequency, TaskData, UserData
-from homeassistant.components.automation import automations_with_entity
-from homeassistant.components.script import scripts_with_entity
-from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
-def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
+def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None:
"""Calculate due date for dailies and yesterdailies."""
- if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due
+ if task.everyX == 0 or not task.nextDue: # grey dailies never become due
+ return None
+ if task.frequency is Frequency.WEEKLY and not any(asdict(task.repeat).values()):
return None
- today = to_date(last_cron)
- startdate = to_date(task["startDate"])
if TYPE_CHECKING:
- assert today
- assert startdate
+ assert task.startDate
- if task["isDue"] and not task["completed"]:
- return to_date(last_cron)
+ if task.isDue is True and not task.completed:
+ return dt_util.as_local(today).date()
- if startdate > today:
- if task["frequency"] == "daily" or (
- task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
+ if task.startDate > today:
+ if task.frequency is Frequency.DAILY or (
+ task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
):
- return startdate
+ return dt_util.as_local(task.startDate).date()
if (
- task["frequency"] in ("weekly", "monthly")
- and (nextdue := to_date(task["nextDue"][0]))
- and startdate > nextdue
+ task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
+ and (nextdue := task.nextDue[0])
+ and task.startDate > nextdue
):
- return to_date(task["nextDue"][1])
+ return dt_util.as_local(task.nextDue[1]).date()
- return to_date(task["nextDue"][0])
-
-
-def to_date(date: str) -> datetime.date | None:
- """Convert an iso date to a datetime.date object."""
- try:
- return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
- except ValueError:
- # sometimes nextDue dates are JavaScript datetime strings instead of iso:
- # "Mon May 06 2024 00:00:00 GMT+0200"
- try:
- return dt_util.as_local(
- datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
- ).date()
- except ValueError:
- return None
-
-
-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
+ return dt_util.as_local(task.nextDue[0]).date()
FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY}
WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU}
-def build_rrule(task: dict[str, Any]) -> rrule:
+def build_rrule(task: TaskData) -> rrule:
"""Build rrule string."""
- rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY)
- weekdays = [
- WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active
- ]
+ if TYPE_CHECKING:
+ assert task.frequency
+ assert task.everyX
+ rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY)
+ weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)]
bymonthday = (
- task["daysOfMonth"]
- if rrule_frequency == MONTHLY and task["daysOfMonth"]
- else None
+ task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None
)
bysetpos = None
- if rrule_frequency == MONTHLY and task["weeksOfMonth"]:
- bysetpos = task["weeksOfMonth"]
+ if rrule_frequency == MONTHLY and task.weeksOfMonth:
+ bysetpos = task.weeksOfMonth
weekdays = weekdays if weekdays else [MO]
return rrule(
freq=rrule_frequency,
- interval=task["everyX"],
- dtstart=dt_util.start_of_local_day(
- datetime.datetime.fromisoformat(task["startDate"])
- ),
+ interval=task.everyX,
+ dtstart=dt_util.start_of_local_day(task.startDate),
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
bymonthday=bymonthday,
bysetpos=bysetpos,
@@ -143,49 +116,49 @@ def get_recurrence_rule(recurrence: rrule) -> str:
def get_attribute_points(
- user: dict[str, Any], content: dict[str, Any], attribute: str
+ user: UserData, content: ContentData, attribute: str
) -> dict[str, float]:
- """Get modifiers contributing to strength attribute."""
-
- gear_set = {
- "weapon",
- "armor",
- "head",
- "shield",
- "back",
- "headAccessory",
- "eyewear",
- "body",
- }
+ """Get modifiers contributing to STR/INT/CON/PER attributes."""
equipment = sum(
- stats[attribute]
- for gear in gear_set
- if (equipped := user["items"]["gear"]["equipped"].get(gear))
- and (stats := content["gear"]["flat"].get(equipped))
+ getattr(stats, attribute)
+ for gear in fields(user.items.gear.equipped)
+ if (equipped := getattr(user.items.gear.equipped, gear.name))
+ and (stats := content.gear.flat[equipped])
)
class_bonus = sum(
- stats[attribute] / 2
- for gear in gear_set
- if (equipped := user["items"]["gear"]["equipped"].get(gear))
- and (stats := content["gear"]["flat"].get(equipped))
- and stats["klass"] == user["stats"]["class"]
+ getattr(stats, attribute) / 2
+ for gear in fields(user.items.gear.equipped)
+ if (equipped := getattr(user.items.gear.equipped, gear.name))
+ and (stats := content.gear.flat[equipped])
+ and stats.klass == user.stats.Class
)
+ if TYPE_CHECKING:
+ assert user.stats.lvl
return {
- "level": min(floor(user["stats"]["lvl"] / 2), 50),
+ "level": min(floor(user.stats.lvl / 2), 50),
"equipment": equipment,
"class": class_bonus,
- "allocated": user["stats"][attribute],
- "buffs": user["stats"]["buffs"][attribute],
+ "allocated": getattr(user.stats, attribute),
+ "buffs": getattr(user.stats.buffs, attribute),
}
-def get_attributes_total(
- user: dict[str, Any], content: dict[str, Any], attribute: str
-) -> int:
+def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
"""Get total attribute points."""
return floor(
sum(value for value in get_attribute_points(user, content, attribute).values())
)
+
+
+def inventory_list(
+ user: UserData, content: ContentData, item_type: str
+) -> dict[str, int]:
+ """List inventory items of given type."""
+ return {
+ getattr(content, item_type)[k].text: v
+ for k, v in getattr(user.items, item_type, {}).items()
+ if k != "Saddle"
+ }
diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py
index dfbcfd4c4ac..7224c0f8f7e 100644
--- a/homeassistant/components/hardware/websocket_api.py
+++ b/homeassistant/components/hardware/websocket_api.py
@@ -14,7 +14,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .hardware import async_process_hardware_platforms
diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py
index b8d9f27bcf1..22bc1a6d529 100644
--- a/homeassistant/components/harman_kardon_avr/media_player.py
+++ b/homeassistant/components/harman_kardon_avr/media_player.py
@@ -13,7 +13,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py
index b75ad617b39..b507c0ae112 100644
--- a/homeassistant/components/harmony/config_flow.py
+++ b/homeassistant/components/harmony/config_flow.py
@@ -11,7 +11,6 @@ from aioharmony.hubconnector_websocket import HubConnector
import aiohttp
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.components.remote import (
ATTR_ACTIVITY,
ATTR_DELAY_SECS,
@@ -26,6 +25,10 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ SsdpServiceInfo,
+)
from .const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID
from .util import (
@@ -93,13 +96,13 @@ class HarmonyConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Harmony device."""
_LOGGER.debug("SSDP discovery_info: %s", discovery_info)
parsed_url = urlparse(discovery_info.ssdp_location)
- friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
+ friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]
self._async_abort_entries_match({CONF_HOST: parsed_url.hostname})
diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py
index 41c55bfc855..4dba412a17c 100644
--- a/homeassistant/components/harmony/data.py
+++ b/homeassistant/components/harmony/data.py
@@ -124,8 +124,7 @@ class HarmonyData(HarmonySubscriberMixin):
except (ValueError, AttributeError) as err:
await self._client.close()
raise ConfigEntryNotReady(
- f"{self.name}: Error {err} while connected HUB at:"
- f" {self._address}:8088"
+ f"{self.name}: Error {err} while connected HUB at: {self._address}:8088"
) from err
if not connected:
await self._client.close()
diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json
index d37801376ec..aab4f51b09a 100644
--- a/homeassistant/components/harmony/manifest.json
+++ b/homeassistant/components/harmony/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/harmony",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
- "requirements": ["aioharmony==0.2.10"],
+ "requirements": ["aioharmony==0.4.1"],
"ssdp": [
{
"manufacturer": "Logitech",
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
index efbd4b2ac02..43bf0a348c0 100644
--- a/homeassistant/components/harmony/remote.py
+++ b/homeassistant/components/harmony/remote.py
@@ -20,8 +20,7 @@ from homeassistant.components.remote import (
RemoteEntityFeature,
)
from homeassistant.core import HassJob, HomeAssistant, callback
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json
index e13573a9ea3..577eb308d78 100644
--- a/homeassistant/components/harmony/strings.json
+++ b/homeassistant/components/harmony/strings.json
@@ -28,7 +28,7 @@
"options": {
"step": {
"init": {
- "description": "Adjust Harmony Hub Options",
+ "description": "Adjust Harmony Hub options",
"data": {
"activity": "The default activity to execute when none is specified.",
"delay_secs": "The delay between sending commands."
@@ -53,7 +53,7 @@
},
"change_channel": {
"name": "Change channel",
- "description": "Sends change channel command to the Harmony HUB.",
+ "description": "Sends a change channel command to the Harmony Hub.",
"fields": {
"channel": {
"name": "Channel",
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index fec84737e78..d71b2b85f7b 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -90,6 +90,7 @@ from .const import (
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
+ DATA_COMPONENT,
DATA_CORE_INFO,
DATA_HOST_INFO,
DATA_INFO,
@@ -115,7 +116,7 @@ from .coordinator import (
get_supervisor_info, # noqa: F401
get_supervisor_stats, # noqa: F401
)
-from .discovery import async_setup_discovery_view # noqa: F401
+from .discovery import async_setup_discovery_view
from .handler import ( # noqa: F401
HassIO,
HassioAPIError,
@@ -326,7 +327,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
- hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host)
+ hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
supervisor_client = get_supervisor_client(hass)
try:
diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py
index 6ca89ee24be..8589bc0f134 100644
--- a/homeassistant/components/hassio/auth.py
+++ b/homeassistant/components/hassio/auth.py
@@ -14,7 +14,7 @@ from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME
diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py
index 23a0b5bd5d8..ddaa821587f 100644
--- a/homeassistant/components/hassio/backup.py
+++ b/homeassistant/components/hassio/backup.py
@@ -5,9 +5,12 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import logging
-from pathlib import Path
+import os
+from pathlib import Path, PurePath
from typing import Any, cast
+from uuid import UUID
+from aiohasupervisor import SupervisorClient
from aiohasupervisor.exceptions import (
SupervisorBadRequestError,
SupervisorError,
@@ -17,29 +20,47 @@ from aiohasupervisor.models import (
backups as supervisor_backups,
mounts as supervisor_mounts,
)
+from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE
from homeassistant.components.backup import (
DATA_MANAGER,
AddonInfo,
AgentBackup,
BackupAgent,
+ BackupManagerError,
+ BackupNotFound,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
+ CreateBackupStage,
+ CreateBackupState,
Folder,
+ IdleEvent,
IncorrectPasswordError,
+ ManagerBackup,
NewBackup,
+ RestoreBackupEvent,
+ RestoreBackupStage,
+ RestoreBackupState,
WrittenBackup,
+ async_get_manager as async_get_backup_manager,
+ suggested_filename as suggested_backup_filename,
+ suggested_filename_from_name_date,
)
+from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util import dt as dt_util
+from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
-LOCATION_CLOUD_BACKUP = ".cloud_backup"
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
+RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
+# Set on backups automatically created when updating an addon
+TAG_ADDON_UPDATE = "supervisor.addon_update"
_LOGGER = logging.getLogger(__name__)
@@ -50,7 +71,9 @@ async def async_get_backup_agents(
"""Return the hassio backup agents."""
client = get_supervisor_client(hass)
mounts = await client.mounts.info()
- agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)]
+ agents: list[BackupAgent] = [
+ SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE)
+ ]
for mount in mounts.mounts:
if mount.usage is not supervisor_mounts.MountUsage.BACKUP:
continue
@@ -90,7 +113,7 @@ def async_register_backup_agents_listener(
def _backup_details_to_agent_backup(
- details: supervisor_backups.BackupComplete,
+ details: supervisor_backups.BackupComplete, location: str
) -> AgentBackup:
"""Convert a supervisor backup details object to an agent backup."""
homeassistant_included = details.homeassistant is not None
@@ -102,18 +125,21 @@ def _backup_details_to_agent_backup(
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
for addon in details.addons
]
+ extra_metadata = details.extra or {}
return AgentBackup(
addons=addons,
backup_id=details.slug,
database_included=database_included,
- date=details.date.isoformat(),
+ date=extra_metadata.get(
+ "supervisor.backup_request_date", details.date.isoformat()
+ ),
extra_metadata=details.extra or {},
folders=[Folder(folder) for folder in details.folders],
homeassistant_included=homeassistant_included,
homeassistant_version=details.homeassistant,
name=details.name,
- protected=details.protected,
- size=details.size_bytes,
+ protected=details.location_attributes[location].protected,
+ size=details.location_attributes[location].size_bytes,
)
@@ -122,13 +148,13 @@ class SupervisorBackupAgent(BackupAgent):
domain = DOMAIN
- def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None:
+ def __init__(self, hass: HomeAssistant, name: str, location: str) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._backup_dir = Path("/backups")
self._client = get_supervisor_client(hass)
- self.name = name
+ self.name = self.unique_id = name
self.location = location
async def async_download_backup(
@@ -137,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
- return await self._client.backups.download_backup(
- backup_id,
- options=supervisor_backups.DownloadBackupOptions(location=self.location),
- )
+ try:
+ return await self._client.backups.download_backup(
+ backup_id,
+ options=supervisor_backups.DownloadBackupOptions(
+ location=self.location
+ ),
+ )
+ except SupervisorNotFoundError as err:
+ raise BackupNotFound from err
async def async_upload_backup(
self,
@@ -151,18 +182,34 @@ class SupervisorBackupAgent(BackupAgent):
) -> None:
"""Upload a backup.
- Not required for supervisor, the SupervisorBackupReaderWriter stores files.
+ The upload will be skipped if the backup already exists in the agent's location.
"""
+ if await self.async_get_backup(backup.backup_id):
+ _LOGGER.debug(
+ "Backup %s already exists in location %s",
+ backup.backup_id,
+ self.location,
+ )
+ return
+ stream = await open_stream()
+ upload_options = supervisor_backups.UploadBackupOptions(
+ location={self.location},
+ filename=PurePath(suggested_backup_filename(backup)),
+ )
+ await self._client.backups.upload_backup(
+ stream,
+ upload_options,
+ )
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
backup_list = await self._client.backups.list()
result = []
for backup in backup_list:
- if not backup.locations or self.location not in backup.locations:
+ if self.location not in backup.location_attributes:
continue
details = await self._client.backups.backup_info(backup.slug)
- result.append(_backup_details_to_agent_backup(details))
+ result.append(_backup_details_to_agent_backup(details, self.location))
return result
async def async_get_backup(
@@ -171,10 +218,13 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any,
) -> AgentBackup | None:
"""Return a backup."""
- details = await self._client.backups.backup_info(backup_id)
- if self.location not in details.locations:
+ try:
+ details = await self._client.backups.backup_info(backup_id)
+ except SupervisorNotFoundError:
return None
- return _backup_details_to_agent_backup(details)
+ if self.location not in details.location_attributes:
+ return None
+ return _backup_details_to_agent_backup(details, self.location)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Remove a backup."""
@@ -185,10 +235,6 @@ class SupervisorBackupAgent(BackupAgent):
location={self.location}
),
)
- except SupervisorBadRequestError as err:
- if err.args[0] != "Backup does not exist":
- raise
- _LOGGER.debug("Backup %s does not exist", backup_id)
except SupervisorNotFoundError:
_LOGGER.debug("Backup %s does not exist", backup_id)
@@ -239,8 +285,45 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
for agent_id in agent_ids
if manager.backup_agents[agent_id].domain == DOMAIN
]
- locations = [agent.location for agent in hassio_agents]
+ # Supervisor does not support creating backups spread across multiple
+ # locations, where some locations are encrypted and some are not.
+ # It's inefficient to let core do all the copying so we want to let
+ # supervisor handle as much as possible.
+ # Therefore, we split the locations into two lists: encrypted and decrypted.
+ # The longest list will be sent to supervisor, and the remaining locations
+ # will be handled by async_upload_backup.
+ # If the lists are the same length, it does not matter which one we send,
+ # we send the encrypted list to have a well defined behavior.
+ encrypted_locations: list[str] = []
+ decrypted_locations: list[str] = []
+ agents_settings = manager.config.data.agents
+ for hassio_agent in hassio_agents:
+ if password is not None:
+ if agent_settings := agents_settings.get(hassio_agent.agent_id):
+ if agent_settings.protected:
+ encrypted_locations.append(hassio_agent.location)
+ else:
+ decrypted_locations.append(hassio_agent.location)
+ else:
+ encrypted_locations.append(hassio_agent.location)
+ else:
+ decrypted_locations.append(hassio_agent.location)
+ _LOGGER.debug("Encrypted locations: %s", encrypted_locations)
+ _LOGGER.debug("Decrypted locations: %s", decrypted_locations)
+ if hassio_agents:
+ if len(encrypted_locations) >= len(decrypted_locations):
+ locations = encrypted_locations
+ else:
+ locations = decrypted_locations
+ password = None
+ else:
+ locations = []
+ locations = locations or [LOCATION_CLOUD_BACKUP]
+
+ date = dt_util.now().isoformat()
+ extra_metadata = extra_metadata | {"supervisor.backup_request_date": date}
+ filename = suggested_filename_from_name_date(backup_name, date)
try:
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
@@ -250,46 +333,69 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
name=backup_name,
password=password,
compressed=True,
- location=locations or LOCATION_CLOUD_BACKUP,
+ location=locations,
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
+ filename=PurePath(filename),
)
)
except SupervisorError as err:
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
backup_task = self._hass.async_create_task(
self._async_wait_for_backup(
- backup, remove_after_upload=not bool(locations)
+ backup,
+ locations,
+ on_progress=on_progress,
+ remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
- return (NewBackup(backup_job_id=backup.job_id), backup_task)
+ return (NewBackup(backup_job_id=backup.job_id.hex), backup_task)
async def _async_wait_for_backup(
- self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool
+ self,
+ backup: supervisor_backups.NewBackup,
+ locations: list[str],
+ *,
+ on_progress: Callable[[CreateBackupEvent], None],
+ remove_after_upload: bool,
) -> WrittenBackup:
"""Wait for a backup to complete."""
backup_complete = asyncio.Event()
backup_id: str | None = None
+ create_errors: list[dict[str, str]] = []
@callback
- def on_progress(data: Mapping[str, Any]) -> None:
+ def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup progress."""
nonlocal backup_id
+ if not (stage := try_parse_enum(CreateBackupStage, data.get("stage"))):
+ _LOGGER.debug("Unknown create stage: %s", data.get("stage"))
+ else:
+ on_progress(
+ CreateBackupEvent(
+ reason=None, stage=stage, state=CreateBackupState.IN_PROGRESS
+ )
+ )
if data.get("done") is True:
backup_id = data.get("reference")
+ create_errors.extend(data.get("errors", []))
backup_complete.set()
+ unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
try:
- unsub = self._async_listen_job_events(backup.job_id, on_progress)
+ await self._get_job_state(backup.job_id, on_job_progress)
await backup_complete.wait()
finally:
unsub()
- if not backup_id:
- raise BackupReaderWriterError("Backup failed")
+ if not backup_id or create_errors:
+ # We should add more specific error handling here in the future
+ raise BackupReaderWriterError(
+ f"Backup failed: {create_errors or 'no backup_id'}"
+ )
async def open_backup() -> AsyncIterator[bytes]:
try:
@@ -320,7 +426,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
) from err
return WrittenBackup(
- backup=_backup_details_to_agent_backup(details),
+ backup=_backup_details_to_agent_backup(details, locations[0]),
open_stream=open_backup,
release_stream=remove_backup,
)
@@ -340,20 +446,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
for agent_id in agent_ids
if manager.backup_agents[agent_id].domain == DOMAIN
]
- locations = {agent.location for agent in hassio_agents}
+ locations = [agent.location for agent in hassio_agents]
+ locations = locations or [LOCATION_CLOUD_BACKUP]
backup_id = await self._client.backups.upload_backup(
stream,
- supervisor_backups.UploadBackupOptions(
- location=locations or {LOCATION_CLOUD_BACKUP}
- ),
+ supervisor_backups.UploadBackupOptions(location=set(locations)),
)
async def open_backup() -> AsyncIterator[bytes]:
return await self._client.backups.download_backup(backup_id)
async def remove_backup() -> None:
- if locations:
+ if locations != [LOCATION_CLOUD_BACKUP]:
return
await self._client.backups.remove_backup(
backup_id,
@@ -365,7 +470,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
details = await self._client.backups.backup_info(backup_id)
return WrittenBackup(
- backup=_backup_details_to_agent_backup(details),
+ backup=_backup_details_to_agent_backup(details, locations[0]),
open_stream=open_backup,
release_stream=remove_backup,
)
@@ -375,6 +480,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
backup_id: str,
*,
agent_id: str,
+ on_progress: Callable[[RestoreBackupEvent], None],
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
restore_addons: list[str] | None,
@@ -402,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
else None
)
- restore_location: str | None
+ restore_location: str
if manager.backup_agents[agent_id].domain != DOMAIN:
# Download the backup to the supervisor. Supervisor will clean up the backup
# two days after the restore is done.
@@ -428,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
location=restore_location,
),
)
+ except SupervisorNotFoundError as err:
+ raise BackupNotFound from err
except SupervisorBadRequestError as err:
# Supervisor currently does not transmit machine parsable error types
message = err.args[0]
@@ -436,22 +544,98 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
raise HomeAssistantError(message) from err
restore_complete = asyncio.Event()
+ restore_errors: list[dict[str, str]] = []
@callback
- def on_progress(data: Mapping[str, Any]) -> None:
- """Handle backup progress."""
+ def on_job_progress(data: Mapping[str, Any]) -> None:
+ """Handle backup restore progress."""
+ if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
+ _LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
+ else:
+ on_progress(
+ RestoreBackupEvent(
+ reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS
+ )
+ )
if data.get("done") is True:
restore_complete.set()
+ restore_errors.extend(data.get("errors", []))
+ unsub = self._async_listen_job_events(job.job_id, on_job_progress)
try:
- unsub = self._async_listen_job_events(job.job_id, on_progress)
+ await self._get_job_state(job.job_id, on_job_progress)
await restore_complete.wait()
+ if restore_errors:
+ # We should add more specific error handling here in the future
+ raise BackupReaderWriterError(f"Restore failed: {restore_errors}")
finally:
unsub()
+ async def async_resume_restore_progress_after_restart(
+ self,
+ *,
+ on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
+ ) -> None:
+ """Check restore status after core restart."""
+ if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)):
+ _LOGGER.debug("No restore job ID found in environment")
+ return
+
+ restore_job_id = UUID(restore_job_str)
+ _LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
+
+ sent_event = False
+
+ @callback
+ def on_job_progress(data: Mapping[str, Any]) -> None:
+ """Handle backup restore progress."""
+ nonlocal sent_event
+
+ if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
+ _LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
+
+ if data.get("done") is not True:
+ if stage or not sent_event:
+ sent_event = True
+ on_progress(
+ RestoreBackupEvent(
+ reason=None,
+ stage=stage,
+ state=RestoreBackupState.IN_PROGRESS,
+ )
+ )
+ return
+
+ restore_errors = data.get("errors", [])
+ if restore_errors:
+ _LOGGER.warning("Restore backup failed: %s", restore_errors)
+ # We should add more specific error handling here in the future
+ on_progress(
+ RestoreBackupEvent(
+ reason="unknown_error",
+ stage=stage,
+ state=RestoreBackupState.FAILED,
+ )
+ )
+ else:
+ on_progress(
+ RestoreBackupEvent(
+ reason=None, stage=stage, state=RestoreBackupState.COMPLETED
+ )
+ )
+ on_progress(IdleEvent())
+ unsub()
+
+ unsub = self._async_listen_job_events(restore_job_id, on_job_progress)
+ try:
+ await self._get_job_state(restore_job_id, on_job_progress)
+ except SupervisorError as err:
+ _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err)
+ unsub()
+
@callback
def _async_listen_job_events(
- self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]
+ self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
) -> Callable[[], None]:
"""Listen for job events."""
@@ -466,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
if (
data.get("event") != "job"
or not (event_data := data.get("data"))
- or event_data.get("uuid") != job_id
+ or event_data.get("uuid") != job_id.hex
):
return
on_event(event_data)
@@ -475,3 +659,93 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
self._hass, EVENT_SUPERVISOR_EVENT, handle_signal
)
return unsub
+
+ async def _get_job_state(
+ self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
+ ) -> None:
+ """Poll a job for its state."""
+ job = await self._client.jobs.get_job(job_id)
+ _LOGGER.debug("Job state: %s", job)
+ on_event(job.to_dict())
+
+
+async def _default_agent(client: SupervisorClient) -> str:
+ """Return the default agent for creating a backup."""
+ mounts = await client.mounts.info()
+ default_mount = mounts.default_backup_mount
+ return f"hassio.{default_mount if default_mount is not None else 'local'}"
+
+
+async def backup_addon_before_update(
+ hass: HomeAssistant,
+ addon: str,
+ addon_name: str | None,
+ installed_version: str | None,
+) -> None:
+ """Prepare for updating an add-on."""
+ backup_manager = hass.data[DATA_MANAGER]
+ client = get_supervisor_client(hass)
+
+ # Use the password from automatic settings if available
+ if backup_manager.config.data.create_backup.agent_ids:
+ password = backup_manager.config.data.create_backup.password
+ else:
+ password = None
+
+ def addon_update_backup_filter(
+ backups: dict[str, ManagerBackup],
+ ) -> dict[str, ManagerBackup]:
+ """Return addon update backups."""
+ return {
+ backup_id: backup
+ for backup_id, backup in backups.items()
+ if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
+ }
+
+ try:
+ await backup_manager.async_create_backup(
+ agent_ids=[await _default_agent(client)],
+ extra_metadata={TAG_ADDON_UPDATE: addon},
+ include_addons=[addon],
+ include_all_addons=False,
+ include_database=False,
+ include_folders=None,
+ include_homeassistant=False,
+ name=f"{addon_name or addon} {installed_version or ''}",
+ password=password,
+ )
+ except BackupManagerError as err:
+ raise HomeAssistantError(f"Error creating backup: {err}") from err
+ else:
+ try:
+ await backup_manager.async_delete_filtered_backups(
+ include_filter=addon_update_backup_filter,
+ delete_filter=lambda backups: backups,
+ )
+ except BackupManagerError as err:
+ raise HomeAssistantError(f"Error deleting old backups: {err}") from err
+
+
+async def backup_core_before_update(hass: HomeAssistant) -> None:
+ """Prepare for updating core."""
+ backup_manager = async_get_backup_manager(hass)
+ client = get_supervisor_client(hass)
+
+ try:
+ if backup_manager.config.data.create_backup.agent_ids:
+ # Create a backup with automatic settings
+ await backup_manager.async_create_automatic_backup()
+ else:
+ # Create a manual backup
+ await backup_manager.async_create_backup(
+ agent_ids=[await _default_agent(client)],
+ include_addons=None,
+ include_all_addons=False,
+ include_database=True,
+ include_folders=None,
+ include_homeassistant=True,
+ name=f"Home Assistant Core {HAVERSION}",
+ password=None,
+ )
+ except BackupManagerError as err:
+ raise HomeAssistantError(f"Error creating backup: {err}") from err
diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py
index 82ce74832c2..d1cda51ec7b 100644
--- a/homeassistant/components/hassio/const.py
+++ b/homeassistant/components/hassio/const.py
@@ -1,7 +1,16 @@
"""Hass.io const variables."""
+from __future__ import annotations
+
from datetime import timedelta
from enum import StrEnum
+from typing import TYPE_CHECKING
+
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from .handler import HassIO
+
DOMAIN = "hassio"
@@ -64,6 +73,7 @@ UPDATE_KEY_SUPERVISOR = "supervisor"
ADDONS_COORDINATOR = "hassio_addons_coordinator"
+DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
DATA_CORE_INFO = "hassio_core_info"
DATA_CORE_STATS = "hassio_core_stats"
DATA_HOST_INFO = "hassio_host_info"
diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py
index cb1dda8aeed..2d39e740e63 100644
--- a/homeassistant/components/hassio/coordinator.py
+++ b/homeassistant/components/hassio/coordinator.py
@@ -35,6 +35,7 @@ from .const import (
DATA_ADDONS_CHANGELOGS,
DATA_ADDONS_INFO,
DATA_ADDONS_STATS,
+ DATA_COMPONENT,
DATA_CORE_INFO,
DATA_CORE_STATS,
DATA_HOST_INFO,
@@ -56,7 +57,7 @@ from .const import (
SUPERVISOR_CONTAINER,
SupervisorEntityModel,
)
-from .handler import HassIO, HassioAPIError, get_supervisor_client
+from .handler import HassioAPIError, get_supervisor_client
if TYPE_CHECKING:
from .issues import SupervisorIssues
@@ -310,7 +311,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
- self.hassio: HassIO = hass.data[DOMAIN]
+ self.hassio = hass.data[DATA_COMPONENT]
self.data = {}
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py
index 254c392462c..752f535ca04 100644
--- a/homeassistant/components/hassio/handler.py
+++ b/homeassistant/components/hassio/handler.py
@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.singleton import singleton
from homeassistant.loader import bind_hass
-from .const import ATTR_MESSAGE, ATTR_RESULT, DOMAIN, X_HASS_SOURCE
+from .const import ATTR_MESSAGE, ATTR_RESULT, DATA_COMPONENT, X_HASS_SOURCE
_LOGGER = logging.getLogger(__name__)
@@ -72,7 +72,7 @@ async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> bo
The caller of the function should handle HassioAPIError.
"""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
return await hassio.update_diagnostics(diagnostics)
@@ -85,7 +85,7 @@ async def async_create_backup(
The caller of the function should handle HassioAPIError.
"""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
backup_type = "partial" if partial else "full"
command = f"/backups/new/{backup_type}"
return await hassio.send_command(command, payload=payload, timeout=None)
@@ -94,7 +94,7 @@ async def async_create_backup(
@api_data
async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]:
"""Return settings specific to Home Assistant Green."""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
return await hassio.send_command("/os/boards/green", method="get")
@@ -106,7 +106,7 @@ async def async_set_green_settings(
Returns an empty dict.
"""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
return await hassio.send_command(
"/os/boards/green", method="post", payload=settings
)
@@ -115,7 +115,7 @@ async def async_set_green_settings(
@api_data
async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]:
"""Return settings specific to Home Assistant Yellow."""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
return await hassio.send_command("/os/boards/yellow", method="get")
@@ -127,7 +127,7 @@ async def async_set_yellow_settings(
Returns an empty dict.
"""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
return await hassio.send_command(
"/os/boards/yellow", method="post", payload=settings
)
@@ -333,7 +333,7 @@ class HassIO:
@singleton(KEY_SUPERVISOR_CLIENT)
def get_supervisor_client(hass: HomeAssistant) -> SupervisorClient:
"""Return supervisor client."""
- hassio: HassIO = hass.data[DOMAIN]
+ hassio = hass.data[DATA_COMPONENT]
return SupervisorClient(
str(hassio.base_url),
os.environ.get("SUPERVISOR_TOKEN", ""),
diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json
index c9ecf6657e8..ad98beb5baa 100644
--- a/homeassistant/components/hassio/manifest.json
+++ b/homeassistant/components/hassio/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
- "requirements": ["aiohasupervisor==0.2.2b5"],
+ "requirements": ["aiohasupervisor==0.3.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 556a5a13f95..799067b8215 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -362,7 +362,7 @@
},
"addons": {
"name": "Add-ons",
- "description": "List of add-ons to include in the backup. Use the name slug of the add-on."
+ "description": "List of add-ons to include in the backup. Use the name slug of each add-on."
},
"folders": {
"name": "Folders",
@@ -418,11 +418,11 @@
},
"folders": {
"name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]",
- "description": "[%key:component::hassio::services::backup_partial::fields::folders::description%]"
+ "description": "List of directories to restore from the backup."
},
"addons": {
"name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]",
- "description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]"
+ "description": "List of add-ons to restore from the backup. Use the name slug of each add-on."
},
"password": {
"name": "[%key:common::config_flow::data::password%]",
diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py
index fbb3e191f81..8e0585892f5 100644
--- a/homeassistant/components/hassio/update.py
+++ b/homeassistant/components/hassio/update.py
@@ -5,11 +5,7 @@ from __future__ import annotations
from typing import Any
from aiohasupervisor import SupervisorError
-from aiohasupervisor.models import (
- HomeAssistantUpdateOptions,
- OSUpdate,
- StoreAddonUpdate,
-)
+from aiohasupervisor.models import OSUpdate
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.components.update import (
@@ -40,6 +36,7 @@ from .entity import (
HassioOSEntity,
HassioSupervisorEntity,
)
+from .update_helper import update_addon, update_core
ENTITY_DESCRIPTION = UpdateEntityDescription(
name="Update",
@@ -163,13 +160,9 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
**kwargs: Any,
) -> None:
"""Install an update."""
- try:
- await self.coordinator.supervisor_client.store.update_addon(
- self._addon_slug, StoreAddonUpdate(backup=backup)
- )
- except SupervisorError as err:
- raise HomeAssistantError(f"Error updating {self.title}: {err}") from err
-
+ await update_addon(
+ self.hass, self._addon_slug, backup, self.title, self.installed_version
+ )
await self.coordinator.force_info_update_supervisor()
@@ -303,11 +296,4 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
- try:
- await self.coordinator.supervisor_client.homeassistant.update(
- HomeAssistantUpdateOptions(version=version, backup=backup)
- )
- except SupervisorError as err:
- raise HomeAssistantError(
- f"Error updating Home Assistant Core: {err}"
- ) from err
+ await update_core(self.hass, version, backup)
diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py
new file mode 100644
index 00000000000..d801f6b5771
--- /dev/null
+++ b/homeassistant/components/hassio/update_helper.py
@@ -0,0 +1,59 @@
+"""Update helpers for Supervisor."""
+
+from __future__ import annotations
+
+from aiohasupervisor import SupervisorError
+from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+from .handler import get_supervisor_client
+
+
+async def update_addon(
+ hass: HomeAssistant,
+ addon: str,
+ backup: bool,
+ addon_name: str | None,
+ installed_version: str | None,
+) -> None:
+ """Update an addon.
+
+ Optionally make a backup before updating.
+ """
+ client = get_supervisor_client(hass)
+
+ if backup:
+ # pylint: disable-next=import-outside-toplevel
+ from .backup import backup_addon_before_update
+
+ await backup_addon_before_update(hass, addon, addon_name, installed_version)
+
+ try:
+ await client.store.update_addon(addon, StoreAddonUpdate(backup=False))
+ except SupervisorError as err:
+ raise HomeAssistantError(
+ f"Error updating {addon_name or addon}: {err}"
+ ) from err
+
+
+async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> None:
+ """Update core.
+
+ Optionally make a backup before updating.
+ """
+ client = get_supervisor_client(hass)
+
+ if backup:
+ # pylint: disable-next=import-outside-toplevel
+ from .backup import backup_core_before_update
+
+ await backup_core_before_update(hass)
+
+ try:
+ await client.homeassistant.update(
+ HomeAssistantUpdateOptions(version=version, backup=False)
+ )
+ except SupervisorError as err:
+ raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err
diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py
index 954d9ee8a02..c046e20feab 100644
--- a/homeassistant/components/hassio/websocket_api.py
+++ b/homeassistant/components/hassio/websocket_api.py
@@ -9,9 +9,10 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
+from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import Unauthorized
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@@ -23,9 +24,11 @@ from .const import (
ATTR_ENDPOINT,
ATTR_METHOD,
ATTR_SESSION_DATA_USER_ID,
+ ATTR_SLUG,
ATTR_TIMEOUT,
+ ATTR_VERSION,
ATTR_WS_EVENT,
- DOMAIN,
+ DATA_COMPONENT,
EVENT_SUPERVISOR_EVENT,
WS_ID,
WS_TYPE,
@@ -33,7 +36,8 @@ from .const import (
WS_TYPE_EVENT,
WS_TYPE_SUBSCRIBE,
)
-from .handler import HassIO
+from .coordinator import get_supervisor_info
+from .update_helper import update_addon, update_core
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
{vol.Required(ATTR_WS_EVENT): cv.string},
@@ -59,6 +63,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_supervisor_event)
websocket_api.async_register_command(hass, websocket_supervisor_api)
websocket_api.async_register_command(hass, websocket_subscribe)
+ websocket_api.async_register_command(hass, websocket_update_addon)
+ websocket_api.async_register_command(hass, websocket_update_core)
@callback
@@ -113,7 +119,7 @@ async def websocket_supervisor_api(
msg[ATTR_ENDPOINT]
):
raise Unauthorized
- supervisor: HassIO = hass.data[DOMAIN]
+ supervisor = hass.data[DATA_COMPONENT]
command = msg[ATTR_ENDPOINT]
payload = msg.get(ATTR_DATA, {})
@@ -138,3 +144,44 @@ async def websocket_supervisor_api(
)
else:
connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {}))
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(WS_TYPE): "hassio/update/addon",
+ vol.Required("addon"): str,
+ vol.Required("backup"): bool,
+ }
+)
+@websocket_api.async_response
+async def websocket_update_addon(
+ hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Websocket handler to update an addon."""
+ addon_name: str | None = None
+ addon_version: str | None = None
+ addons: list = (get_supervisor_info(hass) or {}).get("addons", [])
+ for addon in addons:
+ if addon[ATTR_SLUG] == msg["addon"]:
+ addon_name = addon[ATTR_NAME]
+ addon_version = addon[ATTR_VERSION]
+ break
+ await update_addon(hass, msg["addon"], msg["backup"], addon_name, addon_version)
+ connection.send_result(msg[WS_ID])
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(WS_TYPE): "hassio/update/core",
+ vol.Required("backup"): bool,
+ }
+)
+@websocket_api.async_response
+async def websocket_update_core(
+ hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Websocket handler to update an addon."""
+ await update_core(hass, None, msg["backup"])
+ connection.send_result(msg[WS_ID])
diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py
index 1aebe696e82..d9d2889848e 100644
--- a/homeassistant/components/haveibeenpwned/sensor.py
+++ b/homeassistant/components/haveibeenpwned/sensor.py
@@ -15,12 +15,11 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_EMAIL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import track_point_in_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py
index fbb6a6b48f9..4d9bbeb9516 100644
--- a/homeassistant/components/hddtemp/sensor.py
+++ b/homeassistant/components/hddtemp/sensor.py
@@ -5,9 +5,9 @@ from __future__ import annotations
from datetime import timedelta
import logging
import socket
-from telnetlib import Telnet # pylint: disable=deprecated-module
from typing import Any
+from telnetlib import Telnet # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py
index 6b4a949c0fc..3e31dd73b5d 100644
--- a/homeassistant/components/hdmi_cec/__init__.py
+++ b/homeassistant/components/hdmi_cec/__init__.py
@@ -33,8 +33,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import discovery, event
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery, event
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE
diff --git a/homeassistant/components/hdmi_cec/strings.json b/homeassistant/components/hdmi_cec/strings.json
index d280cfc1a2b..70848b0514e 100644
--- a/homeassistant/components/hdmi_cec/strings.json
+++ b/homeassistant/components/hdmi_cec/strings.json
@@ -2,15 +2,15 @@
"services": {
"power_on": {
"name": "Power on",
- "description": "Power on all devices which supports it."
+ "description": "Powers on all devices which support this function."
},
"select_device": {
"name": "Select device",
- "description": "Select HDMI device.",
+ "description": "Selects an HDMI device.",
"fields": {
"device": {
"name": "[%key:common::config_flow::data::device%]",
- "description": "Address of device to select. Can be entity_id, physical address or alias from configuration."
+ "description": "Address of device to select. Can be an entity ID, physical address or alias from configuration."
}
}
},
@@ -42,7 +42,7 @@
},
"standby": {
"name": "[%key:common::state::standby%]",
- "description": "Standby all devices which supports it."
+ "description": "Places in standby all devices which support this function."
},
"update": {
"name": "Update",
@@ -50,19 +50,19 @@
},
"volume": {
"name": "Volume",
- "description": "Increases or decreases volume of system.",
+ "description": "Increases or decreases the system volume.",
"fields": {
"down": {
"name": "Down",
- "description": "Decreases volume x levels."
+ "description": "Decreases the volume x levels."
},
"mute": {
"name": "Mute",
- "description": "Mutes audio system."
+ "description": "Mutes the audio system."
},
"up": {
"name": "Up",
- "description": "Increases volume x levels."
+ "description": "Increases the volume x levels."
}
}
}
diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py
index de66315a467..f44156bdcb0 100644
--- a/homeassistant/components/heatmiser/climate.py
+++ b/homeassistant/components/heatmiser/climate.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
index b9b9b30a280..d735469c5cb 100644
--- a/homeassistant/components/heos/__init__.py
+++ b/homeassistant/components/heos/__init__.py
@@ -2,51 +2,28 @@
from __future__ import annotations
-import asyncio
-from dataclasses import dataclass
from datetime import timedelta
-import logging
-from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
-from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.dispatcher import (
- async_dispatcher_connect,
- async_dispatcher_send,
-)
-from homeassistant.util import Throttle
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.typing import ConfigType
from . import services
-from .const import (
- COMMAND_RETRY_ATTEMPTS,
- COMMAND_RETRY_DELAY,
- DOMAIN,
- SIGNAL_HEOS_PLAYER_ADDED,
- SIGNAL_HEOS_UPDATED,
-)
+from .const import DOMAIN
+from .coordinator import HeosConfigEntry, HeosCoordinator
PLATFORMS = [Platform.MEDIA_PLAYER]
MIN_UPDATE_SOURCES = timedelta(seconds=1)
-_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
-@dataclass
-class HeosRuntimeData:
- """Runtime data and coordinators for HEOS config entries."""
-
- controller_manager: ControllerManager
- group_manager: GroupManager
- source_manager: SourceManager
- players: dict[int, HeosPlayer]
-
-
-type HeosConfigEntry = ConfigEntry[HeosRuntimeData]
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the HEOS component."""
+ services.register(hass)
+ return True
async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
@@ -55,62 +32,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
- host = entry.data[CONF_HOST]
- # Setting all_progress_events=False ensures that we only receive a
- # media position update upon start of playback or when media changes
- controller = Heos(host, all_progress_events=False)
- try:
- await controller.connect(auto_reconnect=True)
- # Auto reconnect only operates if initial connection was successful.
- except HeosError as error:
- await controller.disconnect()
- _LOGGER.debug("Unable to connect to controller %s: %s", host, error)
- raise ConfigEntryNotReady from error
+ # Migrate non-string device identifiers.
+ device_registry = dr.async_get(hass)
+ for device in device_registry.devices.get_devices_for_config_entry_id(
+ entry.entry_id
+ ):
+ for domain, player_id in device.identifiers:
+ if domain == DOMAIN and not isinstance(player_id, str):
+ device_registry.async_update_device( # type: ignore[unreachable]
+ device.id, new_identifiers={(DOMAIN, str(player_id))}
+ )
+ break
- # Disconnect when shutting down
- async def disconnect_controller(event):
- await controller.disconnect()
-
- entry.async_on_unload(
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller)
- )
-
- # Get players and sources
- try:
- players = await controller.get_players()
- favorites = {}
- if controller.is_signed_in:
- favorites = await controller.get_favorites()
- else:
- _LOGGER.warning(
- (
- "%s is not logged in to a HEOS account and will be unable to"
- " retrieve HEOS favorites: Use the 'heos.sign_in' service to"
- " sign-in to a HEOS account"
- ),
- host,
- )
- inputs = await controller.get_input_sources()
- except HeosError as error:
- await controller.disconnect()
- _LOGGER.debug("Unable to retrieve players and sources: %s", error)
- raise ConfigEntryNotReady from error
-
- controller_manager = ControllerManager(hass, controller)
- await controller_manager.connect_listeners()
-
- source_manager = SourceManager(favorites, inputs)
- source_manager.connect_update(hass, controller)
-
- group_manager = GroupManager(hass, controller, players)
-
- entry.runtime_data = HeosRuntimeData(
- controller_manager, group_manager, source_manager, players
- )
-
- services.register(hass, controller)
- group_manager.connect_update()
- entry.async_on_unload(group_manager.disconnect_update)
+ coordinator = HeosCoordinator(hass, entry)
+ await coordinator.async_setup()
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -119,362 +55,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Unload a config entry."""
- await entry.runtime_data.controller_manager.disconnect()
-
- services.remove(hass)
-
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-class ControllerManager:
- """Class that manages events of the controller."""
-
- def __init__(self, hass, controller):
- """Init the controller manager."""
- self._hass = hass
- self._device_registry = None
- self._entity_registry = None
- self.controller = controller
- self._signals = []
-
- async def connect_listeners(self):
- """Subscribe to events of interest."""
- self._device_registry = dr.async_get(self._hass)
- self._entity_registry = er.async_get(self._hass)
-
- # Handle controller events
- self._signals.append(
- self.controller.dispatcher.connect(
- heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event
- )
- )
- # Handle connection-related events
- self._signals.append(
- self.controller.dispatcher.connect(
- heos_const.SIGNAL_HEOS_EVENT, self._heos_event
- )
- )
-
- async def disconnect(self):
- """Disconnect subscriptions."""
- for signal_remove in self._signals:
- signal_remove()
- self._signals.clear()
- self.controller.dispatcher.disconnect_all()
- await self.controller.disconnect()
-
- async def _controller_event(self, event, data):
- """Handle controller event."""
- if event == heos_const.EVENT_PLAYERS_CHANGED:
- self.update_ids(data[heos_const.DATA_MAPPED_IDS])
- # Update players
- async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED)
-
- async def _heos_event(self, event):
- """Handle connection event."""
- if event == heos_const.EVENT_CONNECTED:
- try:
- # Retrieve latest players and refresh status
- data = await self.controller.load_players()
- self.update_ids(data[heos_const.DATA_MAPPED_IDS])
- except HeosError as ex:
- _LOGGER.error("Unable to refresh players: %s", ex)
- # Update players
- async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED)
-
- def update_ids(self, mapped_ids: dict[int, int]):
- """Update the IDs in the device and entity registry."""
- # mapped_ids contains the mapped IDs (new:old)
- for new_id, old_id in mapped_ids.items():
- # update device registry
- entry = self._device_registry.async_get_device(
- identifiers={(DOMAIN, old_id)}
- )
- new_identifiers = {(DOMAIN, new_id)}
- if entry:
- self._device_registry.async_update_device(
- entry.id, new_identifiers=new_identifiers
- )
- _LOGGER.debug(
- "Updated device %s identifiers to %s", entry.id, new_identifiers
- )
- # update entity registry
- entity_id = self._entity_registry.async_get_entity_id(
- Platform.MEDIA_PLAYER, DOMAIN, str(old_id)
- )
- if entity_id:
- self._entity_registry.async_update_entity(
- entity_id, new_unique_id=str(new_id)
- )
- _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id)
-
-
-class GroupManager:
- """Class that manages HEOS groups."""
-
- def __init__(
- self, hass: HomeAssistant, controller: Heos, players: dict[int, HeosPlayer]
- ) -> None:
- """Init group manager."""
- self._hass = hass
- self._group_membership: dict[str, str] = {}
- self._disconnect_player_added = None
- self._initialized = False
- self.controller = controller
- self.players = players
- self.entity_id_map: dict[int, str] = {}
-
- def _get_entity_id_to_player_id_map(self) -> dict:
- """Return mapping of all HeosMediaPlayer entity_ids to player_ids."""
- return {v: k for k, v in self.entity_id_map.items()}
-
- async def async_get_group_membership(self) -> dict[str, list[str]]:
- """Return all group members for each player as entity_ids."""
- group_info_by_entity_id: dict[str, list[str]] = {
- player_entity_id: []
- for player_entity_id in self._get_entity_id_to_player_id_map()
- }
-
- try:
- groups = await self.controller.get_groups(refresh=True)
- except HeosError as err:
- _LOGGER.error("Unable to get HEOS group info: %s", err)
- return group_info_by_entity_id
-
- player_id_to_entity_id_map = self.entity_id_map
- for group in groups.values():
- leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id)
- member_entity_ids = [
- player_id_to_entity_id_map[member.player_id]
- for member in group.members
- if member.player_id in player_id_to_entity_id_map
- ]
- # Make sure the group leader is always the first element
- group_info = [leader_entity_id, *member_entity_ids]
- if leader_entity_id:
- group_info_by_entity_id[leader_entity_id] = group_info # type: ignore[assignment]
- for member_entity_id in member_entity_ids:
- group_info_by_entity_id[member_entity_id] = group_info # type: ignore[assignment]
-
- return group_info_by_entity_id
-
- async def async_join_players(
- self, leader_id: int, leader_entity_id: str, member_entity_ids: list[str]
- ) -> None:
- """Create a group a group leader and member players."""
- # Resolve HEOS player_id for each member entity_id
- entity_id_to_player_id_map = self._get_entity_id_to_player_id_map()
- member_ids: list[int] = []
- for member in member_entity_ids:
- member_id = entity_id_to_player_id_map.get(member)
- if not member_id:
- raise HomeAssistantError(
- f"The group member {member} could not be resolved to a HEOS player."
- )
- member_ids.append(member_id)
-
- try:
- await self.controller.create_group(leader_id, member_ids)
- except HeosError as err:
- _LOGGER.error(
- "Failed to group %s with %s: %s",
- leader_entity_id,
- member_entity_ids,
- err,
- )
-
- async def async_unjoin_player(self, player_id: int, player_entity_id: str):
- """Remove `player_entity_id` from any group."""
- try:
- await self.controller.create_group(player_id, [])
- except HeosError as err:
- _LOGGER.error(
- "Failed to ungroup %s: %s",
- player_entity_id,
- err,
- )
-
- async def async_update_groups(self, event, data=None):
- """Update the group membership from the controller."""
- if event in (
- heos_const.EVENT_GROUPS_CHANGED,
- heos_const.EVENT_CONNECTED,
- SIGNAL_HEOS_PLAYER_ADDED,
- ):
- if groups := await self.async_get_group_membership():
- self._group_membership = groups
- _LOGGER.debug("Groups updated due to change event")
- # Let players know to update
- async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED)
- else:
- _LOGGER.debug("Groups empty")
-
- def connect_update(self):
- """Connect listener for when groups change and signal player update."""
- self.controller.dispatcher.connect(
- heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups
- )
- self.controller.dispatcher.connect(
- heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups
- )
-
- # When adding a new HEOS player we need to update the groups.
- async def _async_handle_player_added():
- # Avoid calling async_update_groups when the entity_id map has not been
- # fully populated yet. This may only happen during early startup.
- if len(self.players) <= len(self.entity_id_map) and not self._initialized:
- self._initialized = True
- await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED)
-
- self._disconnect_player_added = async_dispatcher_connect(
- self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added
- )
-
- @callback
- def disconnect_update(self):
- """Disconnect the listeners."""
- if self._disconnect_player_added:
- self._disconnect_player_added()
- self._disconnect_player_added = None
-
- @callback
- def register_media_player(self, player_id: int, entity_id: str) -> CALLBACK_TYPE:
- """Register a media player player_id with it's entity_id so it can be resolved later."""
- self.entity_id_map[player_id] = entity_id
- return lambda: self.unregister_media_player(player_id)
-
- @callback
- def unregister_media_player(self, player_id) -> None:
- """Remove a media player player_id from the entity_id map."""
- self.entity_id_map.pop(player_id, None)
-
- @property
- def group_membership(self):
- """Provide access to group members for player entities."""
- return self._group_membership
-
-
-class SourceManager:
- """Class that manages sources for players."""
-
- def __init__(
- self,
- favorites,
- inputs,
- *,
- retry_delay: int = COMMAND_RETRY_DELAY,
- max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS,
- ) -> None:
- """Init input manager."""
- self.retry_delay = retry_delay
- self.max_retry_attempts = max_retry_attempts
- self.favorites = favorites
- self.inputs = inputs
- self.source_list = self._build_source_list()
-
- def _build_source_list(self):
- """Build a single list of inputs from various types."""
- source_list = []
- source_list.extend([favorite.name for favorite in self.favorites.values()])
- source_list.extend([source.name for source in self.inputs])
- return source_list
-
- async def play_source(self, source: str, player):
- """Determine type of source and play it."""
- index = next(
- (
- index
- for index, favorite in self.favorites.items()
- if favorite.name == source
- ),
- None,
- )
- if index is not None:
- await player.play_favorite(index)
- return
-
- input_source = next(
- (
- input_source
- for input_source in self.inputs
- if input_source.name == source
- ),
- None,
- )
- if input_source is not None:
- await player.play_input_source(input_source)
- return
-
- _LOGGER.error("Unknown source: %s", source)
-
- def get_current_source(self, now_playing_media):
- """Determine current source from now playing media."""
- # Match input by input_name:media_id
- if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT:
- return next(
- (
- input_source.name
- for input_source in self.inputs
- if input_source.input_name == now_playing_media.media_id
- ),
- None,
- )
- # Try matching favorite by name:station or media_id:album_id
- return next(
- (
- source.name
- for source in self.favorites.values()
- if source.name == now_playing_media.station
- or source.media_id == now_playing_media.album_id
- ),
- None,
- )
-
- def connect_update(self, hass, controller):
- """Connect listener for when sources change and signal player update.
-
- EVENT_SOURCES_CHANGED is often raised multiple times in response to a
- physical event therefore throttle it. Retrieving sources immediately
- after the event may fail so retry.
- """
-
- @Throttle(MIN_UPDATE_SOURCES)
- async def get_sources():
- retry_attempts = 0
- while True:
- try:
- favorites = {}
- if controller.is_signed_in:
- favorites = await controller.get_favorites()
- inputs = await controller.get_input_sources()
- except HeosError as error:
- if retry_attempts < self.max_retry_attempts:
- retry_attempts += 1
- _LOGGER.debug(
- "Error retrieving sources and will retry: %s", error
- )
- await asyncio.sleep(self.retry_delay)
- else:
- _LOGGER.error("Unable to update sources: %s", error)
- return None
- else:
- return favorites, inputs
-
- async def update_sources(event, data=None):
- if event in (
- heos_const.EVENT_SOURCES_CHANGED,
- heos_const.EVENT_USER_CHANGED,
- heos_const.EVENT_CONNECTED,
- ):
- # If throttled, it will return None
- if sources := await get_sources():
- self.favorites, self.inputs = sources
- self.source_list = self._build_source_list()
- _LOGGER.debug("Sources updated due to changed event")
- # Let players know to update
- async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED)
-
- controller.dispatcher.connect(
- heos_const.SIGNAL_CONTROLLER_EVENT, update_sources
- )
- controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources)
diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py
index f861247d1a9..db2abee559c 100644
--- a/homeassistant/components/heos/config_flow.py
+++ b/homeassistant/components/heos/config_flow.py
@@ -1,16 +1,40 @@
"""Config flow to configure Heos."""
+from collections.abc import Mapping
+import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
-from pyheos import Heos, HeosError
+from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions
import voluptuous as vol
-from homeassistant.components import ssdp
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST
+from homeassistant.config_entries import (
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
+from homeassistant.helpers import selector
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ SsdpServiceInfo,
+)
from .const import DOMAIN
+from .coordinator import HeosConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+AUTH_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_USERNAME): selector.TextSelector(),
+ vol.Optional(CONF_PASSWORD): selector.TextSelector(
+ selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
+ ),
+ }
+)
def format_title(host: str) -> str:
@@ -20,7 +44,7 @@ def format_title(host: str) -> str:
async def _validate_host(host: str, errors: dict[str, str]) -> bool:
"""Validate host is reachable, return True, otherwise populate errors and return False."""
- heos = Heos(host)
+ heos = Heos(HeosOptions(host, events=False, heart_beat=False))
try:
await heos.connect()
except HeosError:
@@ -31,22 +55,70 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
return True
+async def _validate_auth(
+ user_input: dict[str, str], heos: Heos, errors: dict[str, str]
+) -> bool:
+ """Validate authentication by signing in or out, otherwise populate errors if needed."""
+ if not user_input:
+ # Log out (neither username nor password provided)
+ try:
+ await heos.sign_out()
+ except HeosError:
+ errors["base"] = "unknown"
+ _LOGGER.exception("Unexpected error occurred during sign-out")
+ return False
+ else:
+ _LOGGER.debug("Successfully signed-out of HEOS Account")
+ return True
+
+ # Ensure both username and password are provided
+ authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input
+ if authentication and CONF_USERNAME not in user_input:
+ errors[CONF_USERNAME] = "username_missing"
+ return False
+ if authentication and CONF_PASSWORD not in user_input:
+ errors[CONF_PASSWORD] = "password_missing"
+ return False
+
+ # Attempt to login (both username and password provided)
+ try:
+ await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
+ except CommandAuthenticationError as err:
+ errors["base"] = "invalid_auth"
+ _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
+ return False
+ except HeosError:
+ errors["base"] = "unknown"
+ _LOGGER.exception("Unexpected error occurred during sign-in")
+ return False
+ else:
+ _LOGGER.debug(
+ "Successfully signed-in to HEOS Account: %s",
+ heos.signed_in_username,
+ )
+ return True
+
+
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define a flow for HEOS."""
VERSION = 1
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow:
+ """Create the options flow."""
+ return HeosOptionsFlowHandler()
+
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Heos device."""
# Store discovered host
if TYPE_CHECKING:
assert discovery_info.ssdp_location
hostname = urlparse(discovery_info.ssdp_location).hostname
- friendly_name = (
- f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
- )
+ friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN][friendly_name] = hostname
await self.async_set_unique_id(DOMAIN)
@@ -100,3 +172,50 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
errors=errors,
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauthentication after auth failure event."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Validate account credentials and update options."""
+ errors: dict[str, str] = {}
+ entry: HeosConfigEntry = self._get_reauth_entry()
+ if user_input is not None:
+ assert entry.state is ConfigEntryState.LOADED
+ if await _validate_auth(user_input, entry.runtime_data.heos, errors):
+ return self.async_update_reload_and_abort(entry, options=user_input)
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ errors=errors,
+ data_schema=self.add_suggested_values_to_schema(
+ AUTH_SCHEMA, user_input or entry.options
+ ),
+ )
+
+
+class HeosOptionsFlowHandler(OptionsFlow):
+ """Define HEOS options flow."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ entry: HeosConfigEntry = self.config_entry
+ if await _validate_auth(user_input, entry.runtime_data.heos, errors):
+ return self.async_create_entry(data=user_input)
+
+ return self.async_show_form(
+ errors=errors,
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ AUTH_SCHEMA, user_input or self.config_entry.options
+ ),
+ )
diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py
index 5b2df2b5ebf..7f03fa11e79 100644
--- a/homeassistant/components/heos/const.py
+++ b/homeassistant/components/heos/const.py
@@ -2,10 +2,6 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
-COMMAND_RETRY_ATTEMPTS = 2
-COMMAND_RETRY_DELAY = 1
DOMAIN = "heos"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"
-SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added"
-SIGNAL_HEOS_UPDATED = "heos_updated"
diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py
new file mode 100644
index 00000000000..dc8989fd55b
--- /dev/null
+++ b/homeassistant/components/heos/coordinator.py
@@ -0,0 +1,279 @@
+"""HEOS integration coordinator.
+
+Control of all HEOS devices is through connection to a single device. Data is pushed through events.
+The coordinator is responsible for refreshing data in response to system-wide events and notifying
+entities to update. Entities subscribe to entity-specific updates within the entity class itself.
+"""
+
+from collections.abc import Callable, Sequence
+from datetime import datetime, timedelta
+import logging
+from typing import Any
+
+from pyheos import (
+ Credentials,
+ Heos,
+ HeosError,
+ HeosNowPlayingMedia,
+ HeosOptions,
+ MediaItem,
+ MediaType,
+ PlayerUpdateResult,
+ const,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+type HeosConfigEntry = ConfigEntry[HeosCoordinator]
+
+
+class HeosCoordinator(DataUpdateCoordinator[None]):
+ """Define the HEOS integration coordinator."""
+
+ def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None:
+ """Set up the coordinator and set in config_entry."""
+ self.host: str = config_entry.data[CONF_HOST]
+ credentials: Credentials | None = None
+ if config_entry.options:
+ credentials = Credentials(
+ config_entry.options[CONF_USERNAME], config_entry.options[CONF_PASSWORD]
+ )
+ # Setting all_progress_events=False ensures that we only receive a
+ # media position update upon start of playback or when media changes
+ self.heos = Heos(
+ HeosOptions(
+ self.host,
+ all_progress_events=False,
+ auto_reconnect=True,
+ credentials=credentials,
+ )
+ )
+ self._update_sources_pending: bool = False
+ self._source_list: list[str] = []
+ self._favorites: dict[int, MediaItem] = {}
+ self._inputs: Sequence[MediaItem] = []
+ super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN)
+
+ @property
+ def inputs(self) -> Sequence[MediaItem]:
+ """Get input sources across all devices."""
+ return self._inputs
+
+ @property
+ def favorites(self) -> dict[int, MediaItem]:
+ """Get favorite stations."""
+ return self._favorites
+
+ async def async_setup(self) -> None:
+ """Set up the coordinator; connect to the host; and retrieve initial data."""
+ # Add before connect as it may occur during initial connection
+ self.heos.add_on_user_credentials_invalid(self._async_on_auth_failure)
+ # Connect to the device
+ try:
+ await self.heos.connect()
+ except HeosError as error:
+ raise ConfigEntryNotReady from error
+ # Load players
+ try:
+ await self.heos.get_players()
+ except HeosError as error:
+ raise ConfigEntryNotReady from error
+
+ if not self.heos.is_signed_in:
+ _LOGGER.warning(
+ "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
+ )
+ # Retrieve initial data
+ await self._async_update_groups()
+ await self._async_update_sources()
+ # Attach event callbacks
+ self.heos.add_on_disconnected(self._async_on_disconnected)
+ self.heos.add_on_connected(self._async_on_reconnected)
+ self.heos.add_on_controller_event(self._async_on_controller_event)
+
+ async def async_shutdown(self) -> None:
+ """Disconnect all callbacks and disconnect from the device."""
+ self.heos.dispatcher.disconnect_all() # Removes all connected through heos.add_on_* and player.add_on_*
+ await self.heos.disconnect()
+ await super().async_shutdown()
+
+ def async_add_listener(
+ self, update_callback: CALLBACK_TYPE, context: Any = None
+ ) -> Callable[[], None]:
+ """Add a listener for the coordinator."""
+ remove_listener = super().async_add_listener(update_callback, context)
+ # Update entities so group_member entity_ids fully populate.
+ self.async_update_listeners()
+ return remove_listener
+
+ async def _async_on_auth_failure(self) -> None:
+ """Handle when the user credentials are no longer valid."""
+ assert self.config_entry is not None
+ self.config_entry.async_start_reauth(self.hass)
+
+ async def _async_on_disconnected(self) -> None:
+ """Handle when disconnected so entities are marked unavailable."""
+ _LOGGER.warning("Connection to HEOS host %s lost", self.host)
+ self.async_update_listeners()
+
+ async def _async_on_reconnected(self) -> None:
+ """Handle when reconnected so resources are updated and entities marked available."""
+ await self._async_update_players()
+ await self._async_update_sources()
+ _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host)
+ self.async_update_listeners()
+
+ async def _async_on_controller_event(
+ self, event: str, data: PlayerUpdateResult | None
+ ) -> None:
+ """Handle a controller event, such as players or groups changed."""
+ if event == const.EVENT_PLAYERS_CHANGED:
+ assert data is not None
+ if data.updated_player_ids:
+ self._async_update_player_ids(data.updated_player_ids)
+ elif (
+ event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
+ and not self._update_sources_pending
+ ):
+ # Update the sources after a brief delay as we may have received multiple qualifying
+ # events at once and devices cannot handle immediately attempting to refresh sources.
+ self._update_sources_pending = True
+
+ async def update_sources_job(_: datetime | None = None) -> None:
+ await self._async_update_sources()
+ self._update_sources_pending = False
+ self.async_update_listeners()
+
+ assert self.config_entry is not None
+ self.config_entry.async_on_unload(
+ async_call_later(
+ self.hass,
+ timedelta(seconds=1),
+ HassJob(
+ update_sources_job,
+ "heos_update_sources",
+ cancel_on_shutdown=True,
+ ),
+ )
+ )
+ self.async_update_listeners()
+
+ def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None:
+ """Update the IDs in the device and entity registry."""
+ device_registry = dr.async_get(self.hass)
+ entity_registry = er.async_get(self.hass)
+ # updated_player_ids contains the mapped IDs in format old:new
+ for old_id, new_id in updated_player_ids.items():
+ # update device registry
+ entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, str(old_id))}
+ )
+ if entry:
+ new_identifiers = entry.identifiers.copy()
+ new_identifiers.remove((DOMAIN, str(old_id)))
+ new_identifiers.add((DOMAIN, str(new_id)))
+ device_registry.async_update_device(
+ entry.id,
+ new_identifiers=new_identifiers,
+ )
+ _LOGGER.debug(
+ "Updated device %s identifiers to %s", entry.id, new_identifiers
+ )
+ # update entity registry
+ entity_id = entity_registry.async_get_entity_id(
+ Platform.MEDIA_PLAYER, DOMAIN, str(old_id)
+ )
+ if entity_id:
+ entity_registry.async_update_entity(
+ entity_id, new_unique_id=str(new_id)
+ )
+ _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id)
+
+ async def _async_update_groups(self) -> None:
+ """Update group information."""
+ try:
+ await self.heos.get_groups(refresh=True)
+ except HeosError as error:
+ _LOGGER.error("Unable to retrieve groups: %s", error)
+
+ async def _async_update_sources(self) -> None:
+ """Build source list for entities."""
+ self._source_list.clear()
+ # Get favorites only if reportedly signed in.
+ if self.heos.is_signed_in:
+ try:
+ self._favorites = await self.heos.get_favorites()
+ except HeosError as error:
+ _LOGGER.error("Unable to retrieve favorites: %s", error)
+ else:
+ self._source_list.extend(
+ favorite.name for favorite in self._favorites.values()
+ )
+ # Get input sources (across all devices in the HEOS system)
+ try:
+ self._inputs = await self.heos.get_input_sources()
+ except HeosError as error:
+ _LOGGER.error("Unable to retrieve input sources: %s", error)
+ else:
+ self._source_list.extend([source.name for source in self._inputs])
+
+ async def _async_update_players(self) -> None:
+ """Update players after reconnection."""
+ try:
+ player_updates = await self.heos.load_players()
+ except HeosError as error:
+ _LOGGER.error("Unable to refresh players: %s", error)
+ return
+ # After reconnecting, player_id may have changed
+ if player_updates.updated_player_ids:
+ self._async_update_player_ids(player_updates.updated_player_ids)
+
+ @callback
+ def async_get_source_list(self) -> list[str]:
+ """Return the list of sources for players."""
+ return list(self._source_list)
+
+ @callback
+ def async_get_favorite_index(self, name: str) -> int | None:
+ """Get the index of a favorite by name."""
+ for index, favorite in self._favorites.items():
+ if favorite.name == name:
+ return index
+ return None
+
+ @callback
+ def async_get_current_source(
+ self, now_playing_media: HeosNowPlayingMedia
+ ) -> str | None:
+ """Determine current source from now playing media (either input source or favorite)."""
+ # Try matching input source
+ if now_playing_media.source_id == const.MUSIC_SOURCE_AUX_INPUT:
+ # If playing a remote input, name will match station
+ for input_source in self._inputs:
+ if input_source.name == now_playing_media.station:
+ return input_source.name
+ # If playing a local input, match media_id. This needs to be a second loop as media_id
+ # will match both local and remote inputs, so prioritize remote match by name first.
+ for input_source in self._inputs:
+ if input_source.media_id == now_playing_media.media_id:
+ return input_source.name
+ # Try matching favorite
+ if now_playing_media.type == MediaType.STATION:
+ # Some stations match on name:station, others match on media_id:album_id
+ for favorite in self._favorites.values():
+ if (
+ favorite.name == now_playing_media.station
+ or favorite.media_id == now_playing_media.album_id
+ ):
+ return favorite.name
+ return None
diff --git a/homeassistant/components/heos/diagnostics.py b/homeassistant/components/heos/diagnostics.py
new file mode 100644
index 00000000000..bf33fc9bc15
--- /dev/null
+++ b/homeassistant/components/heos/diagnostics.py
@@ -0,0 +1,90 @@
+"""Define the HEOS integration diagnostics module."""
+
+from collections.abc import Mapping, Sequence
+import dataclasses
+from typing import Any
+
+from pyheos import HeosError
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from .const import ATTR_PASSWORD, ATTR_USERNAME, DOMAIN
+from .coordinator import HeosConfigEntry
+
+TO_REDACT = [
+ ATTR_PASSWORD,
+ ATTR_USERNAME,
+ "signed_in_username",
+ "serial",
+ "serial_number",
+]
+
+
+def _as_dict(
+ data: Any, redact: bool = False
+) -> Mapping[str, Any] | Sequence[Any] | Any:
+ """Convert dataclasses to dicts within various data structures."""
+ if dataclasses.is_dataclass(data):
+ data_dict = dataclasses.asdict(data) # type: ignore[arg-type]
+ return data_dict if not redact else async_redact_data(data_dict, TO_REDACT)
+ if not isinstance(data, (Mapping, Sequence)):
+ return data
+ if isinstance(data, Sequence):
+ return [_as_dict(val) for val in data]
+ return {k: _as_dict(v) for k, v in data.items()}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: HeosConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = config_entry.runtime_data
+ diagnostics = {
+ "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
+ "heos": {
+ "connection_state": coordinator.heos.connection_state,
+ "current_credentials": _as_dict(
+ coordinator.heos.current_credentials, redact=True
+ ),
+ },
+ "groups": _as_dict(coordinator.heos.groups),
+ "source_list": coordinator.async_get_source_list(),
+ "inputs": _as_dict(coordinator.inputs),
+ "favorites": _as_dict(coordinator.favorites),
+ }
+ # Try getting system information
+ try:
+ system_info = await coordinator.heos.get_system_info()
+ except HeosError as err:
+ diagnostics["system"] = {"error": str(err)}
+ else:
+ diagnostics["system"] = _as_dict(system_info, redact=True)
+ return diagnostics
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, config_entry: HeosConfigEntry, device: DeviceEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a device."""
+ entity_registry = er.async_get(hass)
+ entities = entity_registry.entities.get_entries_for_device_id(device.id, True)
+ player_id = next(
+ int(value) for domain, value in device.identifiers if domain == DOMAIN
+ )
+ player = config_entry.runtime_data.heos.players.get(player_id)
+ return {
+ "device": async_redact_data(device.dict_repr, TO_REDACT),
+ "entities": [
+ {
+ "entity": entity.as_partial_dict,
+ "state": state.as_dict()
+ if (state := hass.states.get(entity.entity_id))
+ else None,
+ }
+ for entity in entities
+ ],
+ "player": _as_dict(player, redact=True),
+ }
diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json
index 12f10bcd0e3..22dbbf4da28 100644
--- a/homeassistant/components/heos/manifest.json
+++ b/homeassistant/components/heos/manifest.json
@@ -4,9 +4,11 @@
"codeowners": ["@andrewsayre"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/heos",
+ "integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyheos"],
- "requirements": ["pyheos==0.7.2"],
+ "quality_scale": "silver",
+ "requirements": ["pyheos==1.0.1"],
"single_config_entry": true,
"ssdp": [
{
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
index be816849e32..b53cb94d8e7 100644
--- a/homeassistant/components/heos/media_player.py
+++ b/homeassistant/components/heos/media_player.py
@@ -3,12 +3,20 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
+from datetime import datetime
from functools import reduce, wraps
-import logging
from operator import ior
from typing import Any
-from pyheos import HeosError, const as heos_const
+from pyheos import (
+ AddCriteriaType,
+ ControlType,
+ HeosError,
+ HeosPlayer,
+ PlayState,
+ RepeatType,
+ const as heos_const,
+)
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -19,26 +27,28 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
+ RepeatMode,
async_process_play_media_url,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.dispatcher import (
- async_dispatcher_connect,
- async_dispatcher_send,
-)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
-from . import GroupManager, HeosConfigEntry, SourceManager
-from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED
+from .const import DOMAIN as HEOS_DOMAIN
+from .coordinator import HeosConfigEntry, HeosCoordinator
+
+PARALLEL_UPDATES = 0
BASE_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
- | MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.GROUPING
@@ -47,52 +57,55 @@ BASE_SUPPORTED_FEATURES = (
)
PLAY_STATE_TO_STATE = {
- heos_const.PLAY_STATE_PLAY: MediaPlayerState.PLAYING,
- heos_const.PLAY_STATE_STOP: MediaPlayerState.IDLE,
- heos_const.PLAY_STATE_PAUSE: MediaPlayerState.PAUSED,
+ None: MediaPlayerState.IDLE,
+ PlayState.PLAY: MediaPlayerState.PLAYING,
+ PlayState.STOP: MediaPlayerState.IDLE,
+ PlayState.PAUSE: MediaPlayerState.PAUSED,
}
CONTROL_TO_SUPPORT = {
- heos_const.CONTROL_PLAY: MediaPlayerEntityFeature.PLAY,
- heos_const.CONTROL_PAUSE: MediaPlayerEntityFeature.PAUSE,
- heos_const.CONTROL_STOP: MediaPlayerEntityFeature.STOP,
- heos_const.CONTROL_PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK,
- heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK,
+ ControlType.PLAY: MediaPlayerEntityFeature.PLAY,
+ ControlType.PAUSE: MediaPlayerEntityFeature.PAUSE,
+ ControlType.STOP: MediaPlayerEntityFeature.STOP,
+ ControlType.PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK,
+ ControlType.PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK,
}
HA_HEOS_ENQUEUE_MAP = {
- None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
- MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END,
- MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
- MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT,
- MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW,
+ None: AddCriteriaType.REPLACE_AND_PLAY,
+ MediaPlayerEnqueue.ADD: AddCriteriaType.ADD_TO_END,
+ MediaPlayerEnqueue.REPLACE: AddCriteriaType.REPLACE_AND_PLAY,
+ MediaPlayerEnqueue.NEXT: AddCriteriaType.PLAY_NEXT,
+ MediaPlayerEnqueue.PLAY: AddCriteriaType.PLAY_NOW,
}
-_LOGGER = logging.getLogger(__name__)
+HEOS_HA_REPEAT_TYPE_MAP = {
+ RepeatType.OFF: RepeatMode.OFF,
+ RepeatType.ON_ALL: RepeatMode.ALL,
+ RepeatType.ON_ONE: RepeatMode.ONE,
+}
+HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()}
async def async_setup_entry(
hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add media players for a config entry."""
- players = entry.runtime_data.players
devices = [
- HeosMediaPlayer(
- player, entry.runtime_data.source_manager, entry.runtime_data.group_manager
- )
- for player in players.values()
+ HeosMediaPlayer(entry.runtime_data, player)
+ for player in entry.runtime_data.heos.players.values()
]
- async_add_entities(devices, True)
+ async_add_entities(devices)
type _FuncType[**_P] = Callable[_P, Awaitable[Any]]
type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]]
-def log_command_error[**_P](
- command: str,
+def catch_action_error[**_P](
+ action: str,
) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]:
- """Return decorator that logs command failure."""
+ """Return decorator that catches errors and raises HomeAssistantError."""
def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]:
@wraps(func)
@@ -100,116 +113,140 @@ def log_command_error[**_P](
try:
await func(*args, **kwargs)
except (HeosError, ValueError) as ex:
- _LOGGER.error("Unable to %s: %s", command, ex)
+ raise HomeAssistantError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="action_error",
+ translation_placeholders={"action": action, "error": str(ex)},
+ ) from ex
return wrapper
return decorator
-class HeosMediaPlayer(MediaPlayerEntity):
+class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
"""The HEOS player."""
_attr_media_content_type = MediaType.MUSIC
- _attr_should_poll = False
_attr_supported_features = BASE_SUPPORTED_FEATURES
_attr_media_image_remotely_accessible = True
_attr_has_entity_name = True
_attr_name = None
- def __init__(
- self, player, source_manager: SourceManager, group_manager: GroupManager
- ) -> None:
+ def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None:
"""Initialize."""
- self._media_position_updated_at = None
- self._player = player
- self._signals: list = []
- self._source_manager = source_manager
- self._group_manager = group_manager
+ self._media_position_updated_at: datetime | None = None
+ self._player: HeosPlayer = player
self._attr_unique_id = str(player.player_id)
+ model_parts = player.model.split(maxsplit=1)
+ manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS"
+ model = model_parts[1] if len(model_parts) == 2 else player.model
self._attr_device_info = DeviceInfo(
- identifiers={(HEOS_DOMAIN, player.player_id)},
- manufacturer="HEOS",
- model=player.model,
+ identifiers={(HEOS_DOMAIN, str(player.player_id))},
+ manufacturer=manufacturer,
+ model=model,
name=player.name,
+ serial_number=player.serial, # Only available for some models
sw_version=player.version,
)
+ super().__init__(coordinator, context=player.player_id)
- async def _player_update(self, player_id, event):
+ async def _player_update(self, event: str) -> None:
"""Handle player attribute updated."""
- if self._player.player_id != player_id:
- return
if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS:
self._media_position_updated_at = utcnow()
- await self.async_update_ha_state(True)
+ self._handle_coordinator_update()
- async def _heos_updated(self) -> None:
- """Handle sources changed."""
- await self.async_update_ha_state(True)
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_attributes()
+ super()._handle_coordinator_update()
+
+ @callback
+ def _get_group_members(self) -> list[str] | None:
+ """Get group member entity IDs for the group."""
+ if self._player.group_id is None:
+ return None
+ if not (group := self.coordinator.heos.groups.get(self._player.group_id)):
+ return None
+ player_ids = [group.lead_player_id, *group.member_player_ids]
+ # Resolve player_ids to entity_ids
+ entity_registry = er.async_get(self.hass)
+ entity_ids = [
+ entity_id
+ for member_id in player_ids
+ if (
+ entity_id := entity_registry.async_get_entity_id(
+ Platform.MEDIA_PLAYER, HEOS_DOMAIN, str(member_id)
+ )
+ )
+ ]
+ return entity_ids or None
+
+ @callback
+ def _update_attributes(self) -> None:
+ """Update core attributes of the media player."""
+ self._attr_group_members = self._get_group_members()
+ self._attr_source_list = self.coordinator.async_get_source_list()
+ self._attr_source = self.coordinator.async_get_current_source(
+ self._player.now_playing_media
+ )
+ self._attr_repeat = HEOS_HA_REPEAT_TYPE_MAP[self._player.repeat]
+ controls = self._player.now_playing_media.supported_controls
+ current_support = [CONTROL_TO_SUPPORT[control] for control in controls]
+ self._attr_supported_features = reduce(
+ ior, current_support, BASE_SUPPORTED_FEATURES
+ )
+ if self.support_next_track and self.support_previous_track:
+ self._attr_supported_features |= (
+ MediaPlayerEntityFeature.REPEAT_SET
+ | MediaPlayerEntityFeature.SHUFFLE_SET
+ )
async def async_added_to_hass(self) -> None:
"""Device added to hass."""
# Update state when attributes of the player change
- self._signals.append(
- self._player.heos.dispatcher.connect(
- heos_const.SIGNAL_PLAYER_EVENT, self._player_update
- )
- )
- # Update state when heos changes
- self._signals.append(
- async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated)
- )
- # Register this player's entity_id so it can be resolved by the group manager
- self.async_on_remove(
- self._group_manager.register_media_player(
- self._player.player_id, self.entity_id
- )
- )
- async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED)
+ self._update_attributes()
+ self.async_on_remove(self._player.add_on_player_event(self._player_update))
+ await super().async_added_to_hass()
- @log_command_error("clear playlist")
+ @catch_action_error("clear playlist")
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
await self._player.clear_queue()
- @log_command_error("join_players")
- async def async_join_players(self, group_members: list[str]) -> None:
- """Join `group_members` as a player group with the current player."""
- await self._group_manager.async_join_players(
- self._player.player_id, self.entity_id, group_members
- )
-
- @log_command_error("pause")
+ @catch_action_error("pause")
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._player.pause()
- @log_command_error("play")
+ @catch_action_error("play")
async def async_media_play(self) -> None:
"""Send play command."""
await self._player.play()
- @log_command_error("move to previous track")
+ @catch_action_error("move to previous track")
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._player.play_previous()
- @log_command_error("move to next track")
+ @catch_action_error("move to next track")
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._player.play_next()
- @log_command_error("stop")
+ @catch_action_error("stop")
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._player.stop()
- @log_command_error("set mute")
+ @catch_action_error("set mute")
async def async_mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
await self._player.set_mute(mute)
- @log_command_error("play media")
+ @catch_action_error("play media")
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
@@ -244,13 +281,12 @@ class HeosMediaPlayer(MediaPlayerEntity):
return
if media_type == MediaType.PLAYLIST:
- playlists = await self._player.heos.get_playlists()
+ playlists = await self.coordinator.heos.get_playlists()
playlist = next((p for p in playlists if p.name == media_id), None)
if not playlist:
raise ValueError(f"Invalid playlist '{media_id}'")
- add_queue_option = HA_HEOS_ENQUEUE_MAP.get(kwargs.get(ATTR_MEDIA_ENQUEUE))
-
- await self._player.add_to_queue(playlist, add_queue_option)
+ add_queue_option = HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)]
+ await self._player.play_media(playlist, add_queue_option)
return
if media_type == "favorite":
@@ -259,56 +295,89 @@ class HeosMediaPlayer(MediaPlayerEntity):
index = int(media_id)
except ValueError:
# Try finding index by name
- index = next(
- (
- index
- for index, favorite in self._source_manager.favorites.items()
- if favorite.name == media_id
- ),
- None,
- )
+ index = self.coordinator.async_get_favorite_index(media_id)
if index is None:
raise ValueError(f"Invalid favorite '{media_id}'")
- await self._player.play_favorite(index)
+ await self._player.play_preset_station(index)
return
raise ValueError(f"Unsupported media type '{media_type}'")
- @log_command_error("select source")
+ @catch_action_error("select source")
async def async_select_source(self, source: str) -> None:
"""Select input source."""
- await self._source_manager.play_source(source, self._player)
+ # Favorite
+ if (index := self.coordinator.async_get_favorite_index(source)) is not None:
+ await self._player.play_preset_station(index)
+ return
+ # Input source
+ for input_source in self.coordinator.inputs:
+ if input_source.name == source:
+ await self._player.play_media(input_source)
+ return
- @log_command_error("set shuffle")
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="unknown_source",
+ translation_placeholders={"source": source},
+ )
+
+ @catch_action_error("set repeat")
+ async def async_set_repeat(self, repeat: RepeatMode) -> None:
+ """Set repeat mode."""
+ await self._player.set_play_mode(
+ HA_HEOS_REPEAT_TYPE_MAP[repeat], self._player.shuffle
+ )
+
+ @catch_action_error("set shuffle")
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Enable/disable shuffle mode."""
await self._player.set_play_mode(self._player.repeat, shuffle)
- @log_command_error("set volume level")
+ @catch_action_error("set volume level")
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
await self._player.set_volume(int(volume * 100))
- async def async_update(self) -> None:
- """Update supported features of the player."""
- controls = self._player.now_playing_media.supported_controls
- current_support = [CONTROL_TO_SUPPORT[control] for control in controls]
- self._attr_supported_features = reduce(
- ior, current_support, BASE_SUPPORTED_FEATURES
- )
+ @catch_action_error("join players")
+ async def async_join_players(self, group_members: list[str]) -> None:
+ """Join `group_members` as a player group with the current player."""
+ player_ids: list[int] = [self._player.player_id]
+ # Resolve entity_ids to player_ids
+ entity_registry = er.async_get(self.hass)
+ for entity_id in group_members:
+ entity_entry = entity_registry.async_get(entity_id)
+ if entity_entry is None:
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="entity_not_found",
+ translation_placeholders={"entity_id": entity_id},
+ )
+ if entity_entry.platform != HEOS_DOMAIN:
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="not_heos_media_player",
+ translation_placeholders={"entity_id": entity_id},
+ )
+ player_id = int(entity_entry.unique_id)
+ if player_id not in player_ids:
+ player_ids.append(player_id)
+ await self.coordinator.heos.set_group(player_ids)
- @log_command_error("unjoin_player")
+ @catch_action_error("unjoin player")
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
- await self._group_manager.async_unjoin_player(
- self._player.player_id, self.entity_id
- )
-
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect the device when removed."""
- for signal_remove in self._signals:
- signal_remove()
- self._signals.clear()
+ for group in self.coordinator.heos.groups.values():
+ if group.lead_player_id == self._player.player_id:
+ # Player is the group leader, this effectively removes the group.
+ await self.coordinator.heos.set_group([self._player.player_id])
+ return
+ if self._player.player_id in group.member_player_ids:
+ # Player is a group member, update the group to exclude it
+ new_members = [group.lead_player_id, *group.member_player_ids]
+ new_members.remove(self._player.player_id)
+ await self.coordinator.heos.set_group(new_members)
+ return
@property
def available(self) -> bool:
@@ -326,49 +395,46 @@ class HeosMediaPlayer(MediaPlayerEntity):
"media_type": self._player.now_playing_media.type,
}
- @property
- def group_members(self) -> list[str]:
- """List of players which are grouped together."""
- return self._group_manager.group_membership.get(self.entity_id, [])
-
@property
def is_volume_muted(self) -> bool:
"""Boolean if volume is currently muted."""
return self._player.is_muted
@property
- def media_album_name(self) -> str:
+ def media_album_name(self) -> str | None:
"""Album name of current playing media, music track only."""
return self._player.now_playing_media.album
@property
- def media_artist(self) -> str:
+ def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
return self._player.now_playing_media.artist
@property
- def media_content_id(self) -> str:
+ def media_content_id(self) -> str | None:
"""Content ID of current playing media."""
return self._player.now_playing_media.media_id
@property
- def media_duration(self):
+ def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
duration = self._player.now_playing_media.duration
if isinstance(duration, int):
- return duration / 1000
+ return int(duration / 1000)
return None
@property
- def media_position(self):
+ def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
# Some media doesn't have duration but reports position, return None
if not self._player.now_playing_media.duration:
return None
- return self._player.now_playing_media.current_position / 1000
+ if isinstance(self._player.now_playing_media.current_position, int):
+ return int(self._player.now_playing_media.current_position / 1000)
+ return None
@property
- def media_position_updated_at(self):
+ def media_position_updated_at(self) -> datetime | None:
"""When was the position of the current playing media valid."""
# Some media doesn't have duration but reports position, return None
if not self._player.now_playing_media.duration:
@@ -383,7 +449,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
return image_url if image_url else None
@property
- def media_title(self) -> str:
+ def media_title(self) -> str | None:
"""Title of current playing media."""
return self._player.now_playing_media.song
@@ -392,16 +458,6 @@ class HeosMediaPlayer(MediaPlayerEntity):
"""Boolean if shuffle is enabled."""
return self._player.shuffle
- @property
- def source(self) -> str:
- """Name of the current input source."""
- return self._source_manager.get_current_source(self._player.now_playing_media)
-
- @property
- def source_list(self) -> list[str]:
- """List of available input sources."""
- return self._source_manager.source_list
-
@property
def state(self) -> MediaPlayerState:
"""State of the player."""
diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml
index 39c25486e52..f5066d0a743 100644
--- a/homeassistant/components/heos/quality_scale.yaml
+++ b/homeassistant/components/heos/quality_scale.yaml
@@ -1,13 +1,11 @@
rules:
# Bronze
- action-setup:
- status: todo
- comment: Future enhancement to move custom actions for login/out into an options flow.
+ action-setup: done
appropriate-polling:
status: done
comment: Integration is a local push integration
brands: done
- common-modules: todo
+ common-modules: done
config-flow-test-coverage: done
config-flow:
status: done
@@ -16,12 +14,8 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
- entity-event-setup:
- status: todo
- comment: |
- Simplify by using async_on_remove instead of keeping track of listeners to remove
- later in async_will_remove_from_hass.
+ docs-removal-instructions: done
+ entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
@@ -29,57 +23,31 @@ rules:
test-before-setup: done
unique-config-entry: done
# Silver
- action-exceptions:
- status: todo
- comment: Actions currently only log and instead should raise exceptions.
+ action-exceptions: done
config-entry-unloading: done
- docs-configuration-parameters:
- status: done
- comment: |
- The integration doesn't provide any additional configuration parameters.
+ docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
- log-when-unavailable:
- status: todo
- comment: |
- The integration currently spams the logs until reconnected
- parallel-updates:
- status: todo
- comment: Needs to be set to 0. The underlying library handles parallel updates.
- reauthentication-flow:
- status: exempt
- comment: |
- This integration doesn't require re-authentication.
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
test-coverage:
- status: todo
- comment: |
- 1. Integration has >95% coverage, however tests need to be updated to not patch internals.
- 2. test_async_setup_entry_connect_failure and test_async_setup_entry_player_failure -> Instead of
- calling async_setup_entry directly, rather use hass.config_entries.async_setup and then assert
- the config_entry.state is what we expect.
- 3. test_unload_entry -> We should use hass.config_entries.async_unload and assert the entry state
- 4. Recommend using snapshot in test_state_attributes.
- 5. Find a way to avoid using internal dispatcher in test_updates_from_connection_event.
+ status: done
+ comment: 99% test coverage
# Gold
- devices:
- status: todo
- comment: |
- The integraiton creates devices, but needs to stringify the id for the device identifier and
- also migrate the device.
- diagnostics: todo
+ devices: done
+ diagnostics: done
discovery-update-info:
status: todo
comment: Explore if this is possible.
discovery: done
- docs-data-update: todo
- docs-examples: todo
- docs-known-limitations: todo
- docs-supported-devices: todo
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
docs-supported-functions: done
- docs-troubleshooting:
- status: todo
- comment: Has some troublehsooting setps, but needs to be improved
+ docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
@@ -96,4 +64,4 @@ rules:
inject-websession:
status: done
comment: The integration does not use websession
- strict-typing: todo
+ strict-typing: done
diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py
index 2ef80b6efd9..dc11bb7a76d 100644
--- a/homeassistant/components/heos/services.py
+++ b/homeassistant/components/heos/services.py
@@ -1,13 +1,14 @@
"""Services for the HEOS integration."""
-import functools
import logging
-from pyheos import CommandFailedError, Heos, HeosError, const
+from pyheos import CommandAuthenticationError, Heos, HeosError
import voluptuous as vol
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_validation as cv
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from .const import (
ATTR_PASSWORD,
@@ -16,6 +17,7 @@ from .const import (
SERVICE_SIGN_IN,
SERVICE_SIGN_OUT,
)
+from .coordinator import HeosConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -26,49 +28,75 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
-def register(hass: HomeAssistant, controller: Heos):
+def register(hass: HomeAssistant) -> None:
"""Register HEOS services."""
hass.services.async_register(
DOMAIN,
SERVICE_SIGN_IN,
- functools.partial(_sign_in_handler, controller),
+ _sign_in_handler,
schema=HEOS_SIGN_IN_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SIGN_OUT,
- functools.partial(_sign_out_handler, controller),
+ _sign_out_handler,
schema=HEOS_SIGN_OUT_SCHEMA,
)
-def remove(hass: HomeAssistant):
- """Unregister HEOS services."""
- hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN)
- hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT)
+def _get_controller(hass: HomeAssistant) -> Heos:
+ """Get the HEOS controller instance."""
+ _LOGGER.warning(
+ "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release"
+ )
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "sign_in_out_deprecated",
+ breaks_in_ha_version="2025.8.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="sign_in_out_deprecated",
+ )
+
+ entry: HeosConfigEntry | None = (
+ hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN)
+ )
+
+ if not entry or not entry.state == ConfigEntryState.LOADED:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="integration_not_loaded"
+ )
+ return entry.runtime_data.heos
-async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None:
+async def _sign_in_handler(service: ServiceCall) -> None:
"""Sign in to the HEOS account."""
- if controller.connection_state != const.STATE_CONNECTED:
- _LOGGER.error("Unable to sign in because HEOS is not connected")
- return
+ controller = _get_controller(service.hass)
username = service.data[ATTR_USERNAME]
password = service.data[ATTR_PASSWORD]
try:
await controller.sign_in(username, password)
- except CommandFailedError as err:
- _LOGGER.error("Sign in failed: %s", err)
+ except CommandAuthenticationError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="sign_in_auth_error"
+ ) from err
except HeosError as err:
- _LOGGER.error("Unable to sign in: %s", err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="sign_in_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
-async def _sign_out_handler(controller: Heos, service: ServiceCall) -> None:
+async def _sign_out_handler(service: ServiceCall) -> None:
"""Sign out of the HEOS account."""
- if controller.connection_state != const.STATE_CONNECTED:
- _LOGGER.error("Unable to sign out because HEOS is not connected")
- return
+ controller = _get_controller(service.hass)
try:
await controller.sign_out()
except HeosError as err:
- _LOGGER.error("Unable to sign out: %s", err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="sign_out_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json
index fe4fc63b449..4092d4360db 100644
--- a/homeassistant/components/heos/strings.json
+++ b/homeassistant/components/heos/strings.json
@@ -20,17 +20,56 @@
"data_description": {
"host": "[%key:component::heos::config::step::user::data_description::host%]"
}
+ },
+ "reauth_confirm": {
+ "title": "Reauthenticate HEOS",
+ "description": "Please update your HEOS Account credentials. Alternatively, you can clear the credentials if you do not want the integration to access favorites, playlists, and streaming services.",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::heos::options::step::init::data_description::username%]",
+ "password": "[%key:component::heos::options::step::init::data_description::password%]"
+ }
}
},
"error": {
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ "username_missing": "[%key:component::heos::options::error::username_missing%]",
+ "password_missing": "[%key:component::heos::options::error::password_missing%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
+ "options": {
+ "step": {
+ "init": {
+ "title": "HEOS Options",
+ "description": "You can sign-in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign-out of your account.",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The username or email address of your HEOS Account.",
+ "password": "The password to your HEOS Account."
+ }
+ }
+ },
+ "error": {
+ "username_missing": "Username is missing",
+ "password_missing": "Password is missing",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
"services": {
"sign_in": {
"name": "Sign in",
@@ -50,5 +89,37 @@
"name": "Sign out",
"description": "Signs out of the HEOS account."
}
+ },
+ "exceptions": {
+ "action_error": {
+ "message": "Unable to {action}: {error}"
+ },
+ "entity_not_found": {
+ "message": "Entity {entity_id} was not found"
+ },
+ "integration_not_loaded": {
+ "message": "The HEOS integration is not loaded"
+ },
+ "sign_in_auth_error": {
+ "message": "Failed to sign in: Invalid username and/or password"
+ },
+ "sign_in_error": {
+ "message": "Unable to sign in: {error}"
+ },
+ "sign_out_error": {
+ "message": "Unable to sign out: {error}"
+ },
+ "not_heos_media_player": {
+ "message": "Entity {entity_id} is not a HEOS media player entity"
+ },
+ "unknown_source": {
+ "message": "Unknown source: {source}"
+ }
+ },
+ "issues": {
+ "sign_in_out_deprecated": {
+ "title": "HEOS Actions Deprecated",
+ "description": "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release. Enter your HEOS Account credentials in the configuration options and the integration will manage authentication automatically."
+ }
}
}
diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py
index c2b70de148c..6425b5ffbed 100644
--- a/homeassistant/components/here_travel_time/config_flow.py
+++ b/homeassistant/components/here_travel_time/config_flow.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
CONF_NAME,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
EntitySelector,
LocationSelector,
diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py
index 6591f4cb5cc..65e1305e44e 100644
--- a/homeassistant/components/here_travel_time/coordinator.py
+++ b/homeassistant/components/here_travel_time/coordinator.py
@@ -27,7 +27,7 @@ import voluptuous as vol
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.location import find_coordinates
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py
index 0656733db6b..76cca5079e4 100644
--- a/homeassistant/components/hikvision/binary_sensor.py
+++ b/homeassistant/components/hikvision/binary_sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py
index 653d5a07174..aa16097f402 100644
--- a/homeassistant/components/hikvisioncam/switch.py
+++ b/homeassistant/components/hikvisioncam/switch.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py
index d20f6d13217..3694853fb5a 100644
--- a/homeassistant/components/hisense_aehw4a1/__init__.py
+++ b/homeassistant/components/hisense_aehw4a1/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py
index 68f79439162..1dc1eaabcaa 100644
--- a/homeassistant/components/hisense_aehw4a1/climate.py
+++ b/homeassistant/components/hisense_aehw4a1/climate.py
@@ -195,7 +195,7 @@ class ClimateAehW4a1(ClimateEntity):
fan_mode = status["wind_status"]
self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode]
- swing_mode = f'{status["up_down"]}{status["left_right"]}'
+ swing_mode = f"{status['up_down']}{status['left_right']}"
self._attr_swing_mode = AC_TO_HA_SWING[swing_mode]
if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT):
diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py
index 7241e1fac9a..fd82b74b048 100644
--- a/homeassistant/components/history/__init__.py
+++ b/homeassistant/components/history/__init__.py
@@ -15,10 +15,10 @@ from homeassistant.components.recorder import get_instance, history
from homeassistant.components.recorder.util import session_scope
from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE
from homeassistant.core import HomeAssistant, valid_entity_id
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import websocket_api
from .const import DOMAIN
@@ -111,10 +111,12 @@ class HistoryPeriodView(HomeAssistantView):
# end_time. If it's false, we know there are no states in the
# database up until end_time.
(end_time and not has_states_before(hass, end_time))
- or not include_start_time_state
- and entity_ids
- and not entities_may_have_state_changes_after(
- hass, entity_ids, start_time, no_attributes
+ or (
+ not include_start_time_state
+ and entity_ids
+ and not entities_may_have_state_changes_after(
+ hass, entity_ids, start_time, no_attributes
+ )
)
):
return self.json([])
diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py
index 35f8ed5f1ac..c57e766eaed 100644
--- a/homeassistant/components/history/websocket_api.py
+++ b/homeassistant/components/history/websocket_api.py
@@ -35,8 +35,8 @@ from homeassistant.helpers.event import (
async_track_state_change_event,
)
from homeassistant.helpers.json import json_bytes
+from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import create_eager_task
-import homeassistant.util.dt as dt_util
from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES
from .helpers import entities_may_have_state_changes_after, has_states_before
@@ -146,10 +146,12 @@ async def ws_get_history_during_period(
# end_time. If it's false, we know there are no states in the
# database up until end_time.
(end_time and not has_states_before(hass, end_time))
- or not include_start_time_state
- and entity_ids
- and not entities_may_have_state_changes_after(
- hass, entity_ids, start_time, no_attributes
+ or (
+ not include_start_time_state
+ and entity_ids
+ and not entities_may_have_state_changes_after(
+ hass, entity_ids, start_time, no_attributes
+ )
)
):
connection.send_result(msg["id"], {})
diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py
index 83528b73f6f..a69abe26f6c 100644
--- a/homeassistant/components/history_stats/data.py
+++ b/homeassistant/components/history_stats/data.py
@@ -10,7 +10,7 @@ import math
from homeassistant.components.recorder import get_instance, history
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
from homeassistant.helpers.template import Template
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .helpers import async_calculate_period, floored_timestamp
diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py
index 33a45d10735..99214a51369 100644
--- a/homeassistant/components/history_stats/helpers.py
+++ b/homeassistant/components/history_stats/helpers.py
@@ -9,7 +9,7 @@ import math
from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.template import Template
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py
index e1241034aeb..b25daf56598 100644
--- a/homeassistant/components/history_stats/sensor.py
+++ b/homeassistant/components/history_stats/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.reload import async_setup_reload_service
diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py
index 2126f5834ce..25de2d8956e 100644
--- a/homeassistant/components/hitron_coda/device_tracker.py
+++ b/homeassistant/components/hitron_coda/device_tracker.py
@@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py
index 1209e8c8f05..f5648690201 100644
--- a/homeassistant/components/hive/entity.py
+++ b/homeassistant/components/hive/entity.py
@@ -21,7 +21,7 @@ class HiveEntity(Entity):
self.hive = hive
self.device = hive_device
self._attr_name = self.device["haName"]
- self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}'
+ self._attr_unique_id = f"{self.device['hiveID']}-{self.device['hiveType']}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.device["device_id"])},
model=self.device["deviceData"]["model"],
diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py
index 8d09c902f36..e941087c6fb 100644
--- a/homeassistant/components/hive/light.py
+++ b/homeassistant/components/hive/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import refresh_system
from .const import ATTR_MODE, DOMAIN
diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json
index c8062a64ade..219776ad7e6 100644
--- a/homeassistant/components/hive/strings.json
+++ b/homeassistant/components/hive/strings.json
@@ -35,7 +35,7 @@
},
"error": {
"invalid_username": "Failed to sign into Hive. Your email address is not recognised.",
- "invalid_password": "Failed to sign into Hive. Incorrect password please try again.",
+ "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.",
"invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.",
"no_internet_available": "An internet connection is required to connect to Hive.",
"unknown": "[%key:common::config_flow::error::unknown%]"
@@ -52,7 +52,7 @@
"title": "Options for Hive",
"description": "Update the scan interval to poll for data more often.",
"data": {
- "scan_interval": "Scan Interval (seconds)"
+ "scan_interval": "Scan interval (seconds)"
}
}
}
@@ -60,15 +60,15 @@
"services": {
"boost_heating_on": {
"name": "Boost heating on",
- "description": "Sets the boost mode ON defining the period of time and the desired target temperature for the boost.",
+ "description": "Sets the boost mode ON, defining the period of time and the desired target temperature for the boost.",
"fields": {
"time_period": {
- "name": "Time Period",
- "description": "Set the time period for the boost."
+ "name": "[%key:component::hive::services::boost_hot_water::fields::time_period::name%]",
+ "description": "[%key:component::hive::services::boost_hot_water::fields::time_period::description%]"
},
"temperature": {
"name": "Temperature",
- "description": "Set the target temperature for the boost period."
+ "description": "The target temperature for the boost period."
}
}
},
@@ -78,21 +78,21 @@
"fields": {
"entity_id": {
"name": "Entity ID",
- "description": "Select entity_id to turn boost off."
+ "description": "The entity ID to turn boost off."
}
}
},
"boost_hot_water": {
"name": "Boost hotwater",
- "description": "Sets the boost mode ON or OFF defining the period of time for the boost.",
+ "description": "Sets the boost mode ON or OFF, defining the period of time for the boost.",
"fields": {
"entity_id": {
"name": "Entity ID",
- "description": "Select entity_id to boost."
+ "description": "The entity ID to boost."
},
"time_period": {
"name": "Time period",
- "description": "Set the time period for the boost."
+ "description": "The time period for the boost."
},
"on_off": {
"name": "[%key:common::config_flow::data::mode%]",
diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py
index ce37be96dcd..ebd92908b93 100644
--- a/homeassistant/components/hlk_sw16/__init__.py
+++ b/homeassistant/components/hlk_sw16/__init__.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py
index 00a71351ca7..538d9971109 100644
--- a/homeassistant/components/holiday/config_flow.py
+++ b/homeassistant/components/holiday/config_flow.py
@@ -19,6 +19,7 @@ from homeassistant.core import callback
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
+ SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
@@ -30,6 +31,30 @@ from .const import CONF_CATEGORIES, CONF_PROVINCE, DOMAIN
SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False)
+def get_optional_provinces(country: str) -> list[Any]:
+ """Return the country provinces (territories).
+
+ Some territories can have extra or different holidays
+ from another within the same country.
+ Some territories can have different names (aliases).
+ """
+ province_options: list[Any] = []
+
+ if provinces := SUPPORTED_COUNTRIES[country]:
+ country_data = country_holidays(country, years=dt_util.utcnow().year)
+ if country_data.subdivisions_aliases and (
+ subdiv_aliases := country_data.get_subdivision_aliases()
+ ):
+ province_options = [
+ SelectOptionDict(value=k, label=", ".join(v))
+ for k, v in subdiv_aliases.items()
+ ]
+ else:
+ province_options = provinces
+
+ return province_options
+
+
def get_optional_categories(country: str) -> list[str]:
"""Return the country categories.
@@ -45,7 +70,7 @@ def get_optional_categories(country: str) -> list[str]:
def get_options_schema(country: str) -> vol.Schema:
"""Return the options schema."""
schema = {}
- if provinces := SUPPORTED_COUNTRIES[country]:
+ if provinces := get_optional_provinces(country):
schema[vol.Optional(CONF_PROVINCE)] = SelectSelector(
SelectSelectorConfig(
options=provinces,
@@ -64,6 +89,19 @@ def get_options_schema(country: str) -> vol.Schema:
return vol.Schema(schema)
+def get_entry_name(language: str, country: str, province: str | None) -> str:
+ """Generate the entity name from the user language and location."""
+ try:
+ locale = Locale.parse(language, sep="-")
+ except UnknownLocaleError:
+ # Default to (US) English if language not recognized by babel
+ # Mainly an issue with English flavors such as "en-GB"
+ locale = Locale("en")
+ country_str = locale.territories[country] # blocking I/O
+ province_str = f", {province}" if province else ""
+ return f"{country_str}{province_str}"
+
+
class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Holiday."""
@@ -134,15 +172,9 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({**data, **(options or {})})
- try:
- locale = Locale.parse(self.hass.config.language, sep="-")
- except UnknownLocaleError:
- # Default to (US) English if language not recognized by babel
- # Mainly an issue with English flavors such as "en-GB"
- locale = Locale("en")
- province_str = f", {province}" if province else ""
- name = f"{locale.territories[country]}{province_str}"
-
+ name = await self.hass.async_add_executor_job(
+ get_entry_name, self.hass.config.language, country, province
+ )
return self.async_create_entry(title=name, data=data, options=options)
options_schema = await self.hass.async_add_executor_job(
@@ -171,14 +203,9 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({**data, **(options or {})})
- try:
- locale = Locale.parse(self.hass.config.language, sep="-")
- except UnknownLocaleError:
- # Default to (US) English if language not recognized by babel
- # Mainly an issue with English flavors such as "en-GB"
- locale = Locale("en")
- province_str = f", {province}" if province else ""
- name = f"{locale.territories[country]}{province_str}"
+ name = await self.hass.async_add_executor_job(
+ get_entry_name, self.hass.config.language, country, province
+ )
if options:
return self.async_update_reload_and_abort(
diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py
index e33017cd51f..3e81bcbddad 100644
--- a/homeassistant/components/home_connect/light.py
+++ b/homeassistant/components/home_connect/light.py
@@ -18,7 +18,7 @@ from homeassistant.components.light import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .api import HomeConnectDevice
diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py
index 3ccf55bac6e..c11254d2c02 100644
--- a/homeassistant/components/home_connect/sensor.py
+++ b/homeassistant/components/home_connect/sensor.py
@@ -1,13 +1,10 @@
"""Provides a sensor for Home Connect."""
-import contextlib
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import cast
-from homeconnect.api import HomeConnectError
-
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -17,11 +14,11 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, slugify
from . import HomeConnectConfigEntry
from .const import (
+ APPLIANCES_WITH_PROGRAMS,
ATTR_VALUE,
BSH_DOOR_STATE,
BSH_OPERATION_STATE,
@@ -51,27 +48,35 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription):
default_value: str | None = None
appliance_types: tuple[str, ...] | None = None
- sign: int = 1
BSH_PROGRAM_SENSORS = (
HomeConnectSensorEntityDescription(
key="BSH.Common.Option.RemainingProgramTime",
device_class=SensorDeviceClass.TIMESTAMP,
- sign=1,
translation_key="program_finish_time",
+ appliance_types=(
+ "CoffeMaker",
+ "CookProcessor",
+ "Dishwasher",
+ "Dryer",
+ "Hood",
+ "Oven",
+ "Washer",
+ "WasherDryer",
+ ),
),
HomeConnectSensorEntityDescription(
key="BSH.Common.Option.Duration",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
- sign=1,
+ appliance_types=("Oven",),
),
HomeConnectSensorEntityDescription(
key="BSH.Common.Option.ProgramProgress",
native_unit_of_measurement=PERCENTAGE,
- sign=1,
translation_key="program_progress",
+ appliance_types=APPLIANCES_WITH_PROGRAMS,
),
)
@@ -269,11 +274,12 @@ async def async_setup_entry(
if description.appliance_types
and device.appliance.type in description.appliance_types
)
- with contextlib.suppress(HomeConnectError):
- if device.appliance.get_programs_available():
- entities.extend(
- HomeConnectSensor(device, desc) for desc in BSH_PROGRAM_SENSORS
- )
+ entities.extend(
+ HomeConnectProgramSensor(device, desc)
+ for desc in BSH_PROGRAM_SENSORS
+ if desc.appliance_types
+ and device.appliance.type in desc.appliance_types
+ )
entities.extend(
HomeConnectSensor(device, description)
for description in SENSORS
@@ -289,11 +295,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
entity_description: HomeConnectSensorEntityDescription
- @property
- def available(self) -> bool:
- """Return true if the sensor is available."""
- return self._attr_native_value is not None
-
async def async_update(self) -> None:
"""Update the sensor's status."""
appliance_status = self.device.appliance.status
@@ -311,30 +312,17 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
self._attr_native_value = None
elif (
self._attr_native_value is not None
- and self.entity_description.sign == 1
and isinstance(self._attr_native_value, datetime)
and self._attr_native_value < dt_util.utcnow()
):
# if the date is supposed to be in the future but we're
# already past it, set state to None.
self._attr_native_value = None
- elif (
- BSH_OPERATION_STATE
- in (appliance_status := self.device.appliance.status)
- and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE]
- and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE]
- in [
- BSH_OPERATION_STATE_RUN,
- BSH_OPERATION_STATE_PAUSE,
- BSH_OPERATION_STATE_FINISHED,
- ]
- ):
- seconds = self.entity_description.sign * float(status[ATTR_VALUE])
+ else:
+ seconds = float(status[ATTR_VALUE])
self._attr_native_value = dt_util.utcnow() + timedelta(
seconds=seconds
)
- else:
- self._attr_native_value = None
case SensorDeviceClass.ENUM:
# Value comes back as an enum, we only really care about the
# last part, so split it off
@@ -345,3 +333,34 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
case _:
self._attr_native_value = status.get(ATTR_VALUE)
_LOGGER.debug("Updated, new state: %s", self._attr_native_value)
+
+
+class HomeConnectProgramSensor(HomeConnectSensor):
+ """Sensor class for Home Connect sensors that reports information related to the running program."""
+
+ program_running: bool = False
+
+ @property
+ def available(self) -> bool:
+ """Return true if the sensor is available."""
+ # These sensors are only available if the program is running, paused or finished.
+ # Otherwise, some sensors report erroneous values.
+ return super().available and self.program_running
+
+ async def async_update(self) -> None:
+ """Update the sensor's status."""
+ self.program_running = (
+ BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status)
+ and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE]
+ and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE]
+ in [
+ BSH_OPERATION_STATE_RUN,
+ BSH_OPERATION_STATE_PAUSE,
+ BSH_OPERATION_STATE_FINISHED,
+ ]
+ )
+ if self.program_running:
+ await super().async_update()
+ else:
+ # reset the value when the program is not running, paused or finished
+ self._attr_native_value = None
diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json
index f5c3cf69807..7ededaae5b7 100644
--- a/homeassistant/components/home_connect/strings.json
+++ b/homeassistant/components/home_connect/strings.json
@@ -23,7 +23,7 @@
},
"exceptions": {
"appliance_not_found": {
- "message": "Appliance for device id {device_id} not found"
+ "message": "Appliance for device ID {device_id} not found"
},
"turn_on_light": {
"message": "Error turning on {entity_id}: {description}"
@@ -103,7 +103,7 @@
"fields": {
"device_id": {
"name": "Device ID",
- "description": "Id of the device."
+ "description": "ID of the device."
},
"program": { "name": "Program", "description": "Program to select." },
"key": { "name": "Option key", "description": "Key of the option." },
diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py
index 305077bfb86..1bd02e03eb1 100644
--- a/homeassistant/components/home_connect/switch.py
+++ b/homeassistant/components/home_connect/switch.py
@@ -27,7 +27,6 @@ from .const import (
ATTR_VALUE,
BSH_ACTIVE_PROGRAM,
BSH_CHILD_LOCK_STATE,
- BSH_OPERATION_STATE,
BSH_POWER_OFF,
BSH_POWER_ON,
BSH_POWER_STANDBY,
@@ -98,6 +97,12 @@ SWITCHES = (
)
+POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
+ key=BSH_POWER_STATE,
+ translation_key="power",
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
@@ -117,7 +122,8 @@ async def async_setup_entry(
HomeConnectProgramSwitch(device, program)
for program in programs
)
- entities.append(HomeConnectPowerSwitch(device))
+ if BSH_POWER_STATE in device.appliance.status:
+ entities.append(HomeConnectPowerSwitch(device))
entities.extend(
HomeConnectSwitch(device, description)
for description in SWITCHES
@@ -310,7 +316,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
"""Initialize the entity."""
super().__init__(
device,
- SwitchEntityDescription(key=BSH_POWER_STATE, translation_key="power"),
+ POWER_SWITCH_DESCRIPTION,
)
if (
power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get(
@@ -396,23 +402,6 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
== self.power_off_state
):
self._attr_is_on = False
- elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(
- ATTR_VALUE, None
- ) in [
- "BSH.Common.EnumType.OperationState.Ready",
- "BSH.Common.EnumType.OperationState.DelayedStart",
- "BSH.Common.EnumType.OperationState.Run",
- "BSH.Common.EnumType.OperationState.Pause",
- "BSH.Common.EnumType.OperationState.ActionRequired",
- "BSH.Common.EnumType.OperationState.Aborting",
- "BSH.Common.EnumType.OperationState.Finished",
- ]:
- self._attr_is_on = True
- elif (
- self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE)
- == "BSH.Common.EnumType.OperationState.Inactive"
- ):
- self._attr_is_on = False
else:
self._attr_is_on = None
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py
index 7a51e218a16..7fad6728a74 100644
--- a/homeassistant/components/homeassistant/const.py
+++ b/homeassistant/components/homeassistant/const.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Final
-import homeassistant.core as ha
+from homeassistant import core as ha
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py
index bea6e8a66a7..e07d806d3dc 100644
--- a/homeassistant/components/homeassistant/triggers/time.py
+++ b/homeassistant/components/homeassistant/triggers/time.py
@@ -35,14 +35,14 @@ from homeassistant.helpers.event import (
)
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"]))
_TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY)
_TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema(
{
- vol.Required(CONF_ENTITY_ID): cv.entity_domain(["sensor"]),
+ vol.Required(CONF_ENTITY_ID): cv.entity_domain(["input_datetime", "sensor"]),
vol.Optional(CONF_OFFSET): cv.time_period,
}
)
@@ -156,14 +156,17 @@ async def async_attach_trigger(
if has_date:
# If input_datetime has date, then track point in time.
- trigger_dt = datetime(
- year,
- month,
- day,
- hour,
- minute,
- second,
- tzinfo=dt_util.get_default_time_zone(),
+ trigger_dt = (
+ datetime(
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ second,
+ tzinfo=dt_util.get_default_time_zone(),
+ )
+ + offset
)
# Only set up listener if time is now or in the future.
if trigger_dt >= dt_util.now():
@@ -178,6 +181,17 @@ async def async_attach_trigger(
)
elif has_time:
# Else if it has time, then track time change.
+ if offset != timedelta(0):
+ # Create a temporary datetime object to get an offset.
+ temp_dt = dt_util.now().replace(
+ hour=hour, minute=minute, second=second, microsecond=0
+ )
+ temp_dt += offset
+ # Ignore the date and apply the offset even if it wraps
+ # around to the next day.
+ hour = temp_dt.hour
+ minute = temp_dt.minute
+ second = temp_dt.second
remove = async_track_time_change(
hass,
partial(
diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
index a91fb00c142..fac3d2d9735 100644
--- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
+++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
@@ -7,17 +7,12 @@ import asyncio
import logging
from typing import Any
-from universal_silabs_flasher.const import ApplicationType
-
from homeassistant.components.hassio import (
AddonError,
AddonInfo,
AddonManager,
AddonState,
)
-from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
- probe_silabs_firmware_type,
-)
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -32,9 +27,11 @@ from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon
from .const import ZHA_DOMAIN
from .util import (
+ ApplicationType,
get_otbr_addon_manager,
get_zha_device_path,
get_zigbee_flasher_addon_manager,
+ probe_silabs_firmware_type,
)
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json
index f692094bc67..2efa12ccfda 100644
--- a/homeassistant/components/homeassistant_hardware/manifest.json
+++ b/homeassistant/components/homeassistant_hardware/manifest.json
@@ -1,8 +1,9 @@
{
"domain": "homeassistant_hardware",
"name": "Home Assistant Hardware",
- "after_dependencies": ["hassio", "zha"],
+ "after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
- "integration_type": "system"
+ "integration_type": "system",
+ "requirements": ["universal-silabs-flasher==0.0.25"]
}
diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py
index 0c06ff05e5c..3fd5bc60037 100644
--- a/homeassistant/components/homeassistant_hardware/util.py
+++ b/homeassistant/components/homeassistant_hardware/util.py
@@ -3,11 +3,14 @@
from __future__ import annotations
from collections import defaultdict
+from collections.abc import Iterable
from dataclasses import dataclass
+from enum import StrEnum
import logging
from typing import cast
-from universal_silabs_flasher.const import ApplicationType
+from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
+from universal_silabs_flasher.flasher import Flasher
from homeassistant.components.hassio import AddonError, AddonState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@@ -32,6 +35,26 @@ from .silabs_multiprotocol_addon import (
_LOGGER = logging.getLogger(__name__)
+class ApplicationType(StrEnum):
+ """Application type running on a device."""
+
+ GECKO_BOOTLOADER = "bootloader"
+ CPC = "cpc"
+ EZSP = "ezsp"
+ SPINEL = "spinel"
+
+ @classmethod
+ def from_flasher_application_type(
+ cls, app_type: FlasherApplicationType
+ ) -> ApplicationType:
+ """Convert a USF application type enum."""
+ return cls(app_type.value)
+
+ def as_flasher_application_type(self) -> FlasherApplicationType:
+ """Convert the application type enum into one compatible with USF."""
+ return FlasherApplicationType(self.value)
+
+
def get_zha_device_path(config_entry: ConfigEntry) -> str | None:
"""Get the device path from a ZHA config entry."""
return cast(str | None, config_entry.data.get("device", {}).get("path", None))
@@ -137,3 +160,27 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
assert guesses
return guesses[-1]
+
+
+async def probe_silabs_firmware_type(
+ device: str, *, probe_methods: Iterable[ApplicationType] | None = None
+) -> ApplicationType | None:
+ """Probe the running firmware on a Silabs device."""
+ flasher = Flasher(
+ device=device,
+ **(
+ {"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
+ if probe_methods
+ else {}
+ ),
+ )
+
+ try:
+ await flasher.probe_app_type()
+ except Exception: # noqa: BLE001
+ _LOGGER.debug("Failed to probe application type", exc_info=True)
+
+ if flasher.app_type is None:
+ return None
+
+ return ApplicationType.from_flasher_application_type(flasher.app_type)
diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py
index 5c35732312b..b3b4f68ba96 100644
--- a/homeassistant/components/homeassistant_sky_connect/config_flow.py
+++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py
@@ -5,13 +5,12 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Protocol
-from universal_silabs_flasher.const import ApplicationType
-
from homeassistant.components import usb
from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
silabs_multiprotocol_addon,
)
+from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -20,6 +19,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import callback
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant
from .util import get_hardware_variant, get_usb_service_info
@@ -70,7 +70,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Initialize the config flow."""
super().__init__(*args, **kwargs)
- self._usb_info: usb.UsbServiceInfo | None = None
+ self._usb_info: UsbServiceInfo | None = None
self._hw_variant: HardwareVariant | None = None
@staticmethod
@@ -86,9 +86,7 @@ class HomeAssistantSkyConnectConfigFlow(
return HomeAssistantSkyConnectOptionsFlowHandler(config_entry)
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
device = discovery_info.device
vid = discovery_info.vid
@@ -146,11 +144,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
self,
) -> silabs_multiprotocol_addon.SerialPortSettings:
"""Return the radio serial port settings."""
- usb_dev = self.config_entry.data["device"]
- # The call to get_serial_by_id can be removed in HA Core 2024.1
- dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, usb_dev)
return silabs_multiprotocol_addon.SerialPortSettings(
- device=dev_path,
+ device=self.config_entry.data["device"],
baudrate="115200",
flow_control=True,
)
diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py
index f8c5d004d0e..c463c1b9275 100644
--- a/homeassistant/components/homeassistant_sky_connect/util.py
+++ b/homeassistant/components/homeassistant_sky_connect/util.py
@@ -4,17 +4,17 @@ from __future__ import annotations
import logging
-from homeassistant.components import usb
from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import HardwareVariant
_LOGGER = logging.getLogger(__name__)
-def get_usb_service_info(config_entry: ConfigEntry) -> usb.UsbServiceInfo:
+def get_usb_service_info(config_entry: ConfigEntry) -> UsbServiceInfo:
"""Return UsbServiceInfo."""
- return usb.UsbServiceInfo(
+ return UsbServiceInfo(
device=config_entry.data["device"],
vid=config_entry.data["vid"],
pid=config_entry.data["pid"],
diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py
index 2c58ecdfc1c..502a20db07c 100644
--- a/homeassistant/components/homeassistant_yellow/config_flow.py
+++ b/homeassistant/components/homeassistant_yellow/config_flow.py
@@ -8,7 +8,6 @@ import logging
from typing import Any, final
import aiohttp
-from universal_silabs_flasher.const import ApplicationType
import voluptuous as vol
from homeassistant.components.hassio import (
@@ -25,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings,
)
+from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py
new file mode 100644
index 00000000000..9837d6094ff
--- /dev/null
+++ b/homeassistant/components/homee/__init__.py
@@ -0,0 +1,85 @@
+"""The Homee integration."""
+
+import logging
+
+from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS = [Platform.COVER, Platform.SENSOR]
+
+type HomeeConfigEntry = ConfigEntry[Homee]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
+ """Set up homee from a config entry."""
+ # Create the Homee api object using host, user,
+ # password & pyHomee instance from the config
+ homee = Homee(
+ host=entry.data[CONF_HOST],
+ user=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ device="HA_" + hass.config.location_name,
+ reconnect_interval=10,
+ max_retries=100,
+ )
+
+ # Start the homee websocket connection as a new task
+ # and wait until we are connected
+ try:
+ await homee.get_access_token()
+ except HomeeConnectionFailedException as exc:
+ raise ConfigEntryNotReady(
+ f"Connection to Homee failed: {exc.__cause__}"
+ ) from exc
+ except HomeeAuthFailedException as exc:
+ raise ConfigEntryNotReady(
+ f"Authentication to Homee failed: {exc.__cause__}"
+ ) from exc
+
+ hass.loop.create_task(homee.run())
+ await homee.wait_until_connected()
+
+ entry.runtime_data = homee
+ entry.async_on_unload(homee.disconnect)
+
+ def _connection_update_callback(connected: bool) -> None:
+ """Call when the device is notified of changes."""
+ if connected:
+ _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST])
+ else:
+ _LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST])
+
+ homee.add_connection_listener(_connection_update_callback)
+
+ # create device register entry
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections={
+ (dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
+ },
+ identifiers={(DOMAIN, homee.settings.uid)},
+ manufacturer="homee",
+ name=homee.settings.homee_name,
+ model="homee",
+ sw_version=homee.settings.version,
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
+ """Unload a homee config entry."""
+ # Unload platforms
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py
new file mode 100644
index 00000000000..61d2a3f25a5
--- /dev/null
+++ b/homeassistant/components/homee/config_flow.py
@@ -0,0 +1,85 @@
+"""Config flow for homee integration."""
+
+import logging
+from typing import Any
+
+from pyHomee import (
+ Homee,
+ HomeeAuthFailedException as HomeeAuthenticationFailedException,
+ HomeeConnectionFailedException,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+AUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+
+class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for homee."""
+
+ VERSION = 1
+
+ homee: Homee
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial user step."""
+
+ errors = {}
+ if user_input is not None:
+ self.homee = Homee(
+ user_input[CONF_HOST],
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ )
+
+ try:
+ await self.homee.get_access_token()
+ except HomeeConnectionFailedException:
+ errors["base"] = "cannot_connect"
+ except HomeeAuthenticationFailedException:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ _LOGGER.info("Got access token for homee")
+ self.hass.loop.create_task(self.homee.run())
+ _LOGGER.debug("Homee task created")
+ await self.homee.wait_until_connected()
+ _LOGGER.info("Homee connected")
+ self.homee.disconnect()
+ _LOGGER.debug("Homee disconnecting")
+ await self.homee.wait_until_disconnected()
+ _LOGGER.info("Homee config successfully tested")
+
+ await self.async_set_unique_id(self.homee.settings.uid)
+
+ self._abort_if_unique_id_configured()
+
+ _LOGGER.info(
+ "Created new homee entry with ID %s", self.homee.settings.uid
+ )
+
+ return self.async_create_entry(
+ title=f"{self.homee.settings.homee_name} ({self.homee.host})",
+ data=user_input,
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=AUTH_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py
new file mode 100644
index 00000000000..1d7ce27335f
--- /dev/null
+++ b/homeassistant/components/homee/const.py
@@ -0,0 +1,64 @@
+"""Constants for the homee integration."""
+
+from homeassistant.const import (
+ DEGREE,
+ LIGHT_LUX,
+ PERCENTAGE,
+ REVOLUTIONS_PER_MINUTE,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfPower,
+ UnitOfSpeed,
+ UnitOfTemperature,
+ UnitOfTime,
+ UnitOfVolume,
+)
+
+# General
+DOMAIN = "homee"
+
+# Sensor mappings
+HOMEE_UNIT_TO_HA_UNIT = {
+ "": None,
+ "n/a": None,
+ "text": None,
+ "%": PERCENTAGE,
+ "lx": LIGHT_LUX,
+ "klx": LIGHT_LUX,
+ "1/min": REVOLUTIONS_PER_MINUTE,
+ "A": UnitOfElectricCurrent.AMPERE,
+ "V": UnitOfElectricPotential.VOLT,
+ "kWh": UnitOfEnergy.KILO_WATT_HOUR,
+ "W": UnitOfPower.WATT,
+ "m/s": UnitOfSpeed.METERS_PER_SECOND,
+ "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
+ "°": DEGREE,
+ "°F": UnitOfTemperature.FAHRENHEIT,
+ "°C": UnitOfTemperature.CELSIUS,
+ "K": UnitOfTemperature.KELVIN,
+ "s": UnitOfTime.SECONDS,
+ "min": UnitOfTime.MINUTES,
+ "h": UnitOfTime.HOURS,
+ "L": UnitOfVolume.LITERS,
+}
+OPEN_CLOSE_MAP = {
+ 0.0: "open",
+ 1.0: "closed",
+ 2.0: "partial",
+ 3.0: "opening",
+ 4.0: "closing",
+}
+OPEN_CLOSE_MAP_REVERSED = {
+ 0.0: "closed",
+ 1.0: "open",
+ 2.0: "partial",
+ 3.0: "closing",
+ 4.0: "opening",
+}
+WINDOW_MAP = {
+ 0.0: "closed",
+ 1.0: "open",
+ 2.0: "tilted",
+}
+WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"}
diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py
new file mode 100644
index 00000000000..b4a853f7c35
--- /dev/null
+++ b/homeassistant/components/homee/cover.py
@@ -0,0 +1,277 @@
+"""The homee cover platform."""
+
+import logging
+from typing import Any, cast
+
+from pyHomee.const import AttributeType, NodeProfile
+from pyHomee.model import HomeeAttribute, HomeeNode
+
+from homeassistant.components.cover import (
+ ATTR_POSITION,
+ ATTR_TILT_POSITION,
+ CoverDeviceClass,
+ CoverEntity,
+ CoverEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import HomeeConfigEntry
+from .entity import HomeeNodeEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+OPEN_CLOSE_ATTRIBUTES = [
+ AttributeType.OPEN_CLOSE,
+ AttributeType.SLAT_ROTATION_IMPULSE,
+ AttributeType.UP_DOWN,
+]
+POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
+
+
+def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
+ """Return the attribute used for opening/closing the cover."""
+ # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
+ if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None:
+ open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE)
+
+ return open_close
+
+
+def get_cover_features(
+ node: HomeeNode, open_close_attribute: HomeeAttribute | None
+) -> CoverEntityFeature:
+ """Determine the supported cover features of a homee node based on the available attributes."""
+ features = CoverEntityFeature(0)
+
+ if (open_close_attribute is not None) and open_close_attribute.editable:
+ features |= (
+ CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
+ )
+
+ # Check for up/down position settable.
+ attribute = node.get_attribute_by_type(AttributeType.POSITION)
+ if attribute is not None:
+ if attribute.editable:
+ features |= CoverEntityFeature.SET_POSITION
+
+ if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None:
+ features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
+
+ if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None:
+ features |= CoverEntityFeature.SET_TILT_POSITION
+
+ return features
+
+
+def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
+ """Determine the device class a homee node based on the node profile."""
+ COVER_DEVICE_PROFILES = {
+ NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
+ NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
+ }
+
+ return COVER_DEVICE_PROFILES.get(node.profile)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_devices: AddEntitiesCallback,
+) -> None:
+ """Add the homee platform for the cover integration."""
+
+ async_add_devices(
+ HomeeCover(node, config_entry)
+ for node in config_entry.runtime_data.nodes
+ if is_cover_node(node)
+ )
+
+
+def is_cover_node(node: HomeeNode) -> bool:
+ """Determine if a node is controllable as a homee cover based on its profile and attributes."""
+ return node.profile in [
+ NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
+ NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
+ NodeProfile.GARAGE_DOOR_OPERATOR,
+ NodeProfile.SHUTTER_POSITION_SWITCH,
+ ]
+
+
+class HomeeCover(HomeeNodeEntity, CoverEntity):
+ """Representation of a homee cover device."""
+
+ _attr_name = None
+
+ def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ """Initialize a homee cover entity."""
+ super().__init__(node, entry)
+ self._open_close_attribute = get_open_close_attribute(node)
+ self._attr_supported_features = get_cover_features(
+ node, self._open_close_attribute
+ )
+ self._attr_device_class = get_device_class(node)
+ self._attr_unique_id = (
+ f"{self._attr_unique_id}-{self._open_close_attribute.id}"
+ if self._open_close_attribute is not None
+ else f"{self._attr_unique_id}-0"
+ )
+
+ @property
+ def current_cover_position(self) -> int | None:
+ """Return the cover's position."""
+ # Translate the homee position values to HA's 0-100 scale
+ if (
+ attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
+ ) is not None:
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = attribute.current_value
+ position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
+
+ return int(100 - position)
+
+ return None
+
+ @property
+ def current_cover_tilt_position(self) -> int | None:
+ """Return the cover's tilt position."""
+ # Translate the homee position values to HA's 0-100 scale
+ if (
+ attribute := self._node.get_attribute_by_type(
+ AttributeType.SHUTTER_SLAT_POSITION
+ )
+ ) is not None:
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = attribute.current_value
+ position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
+
+ return int(100 - position)
+
+ return None
+
+ @property
+ def is_opening(self) -> bool | None:
+ """Return the opening status of the cover."""
+ if self._open_close_attribute is not None:
+ return (
+ self._open_close_attribute.get_value() == 3
+ if not self._open_close_attribute.is_reversed
+ else self._open_close_attribute.get_value() == 4
+ )
+
+ return None
+
+ @property
+ def is_closing(self) -> bool | None:
+ """Return the closing status of the cover."""
+ if self._open_close_attribute is not None:
+ return (
+ self._open_close_attribute.get_value() == 4
+ if not self._open_close_attribute.is_reversed
+ else self._open_close_attribute.get_value() == 3
+ )
+
+ return None
+
+ @property
+ def is_closed(self) -> bool | None:
+ """Return if the cover is closed."""
+ if (
+ attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
+ ) is not None:
+ return attribute.get_value() == attribute.maximum
+
+ if self._open_close_attribute is not None:
+ if not self._open_close_attribute.is_reversed:
+ return self._open_close_attribute.get_value() == 1
+
+ return self._open_close_attribute.get_value() == 0
+
+ # If none of the above is present, it might be a slat only cover.
+ if (
+ attribute := self._node.get_attribute_by_type(
+ AttributeType.SHUTTER_SLAT_POSITION
+ )
+ ) is not None:
+ return attribute.get_value() == attribute.minimum
+
+ return None
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the cover."""
+ assert self._open_close_attribute is not None
+ if not self._open_close_attribute.is_reversed:
+ await self.async_set_value(self._open_close_attribute, 0)
+ else:
+ await self.async_set_value(self._open_close_attribute, 1)
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close cover."""
+ assert self._open_close_attribute is not None
+ if not self._open_close_attribute.is_reversed:
+ await self.async_set_value(self._open_close_attribute, 1)
+ else:
+ await self.async_set_value(self._open_close_attribute, 0)
+
+ async def async_set_cover_position(self, **kwargs: Any) -> None:
+ """Move the cover to a specific position."""
+ if CoverEntityFeature.SET_POSITION in self.supported_features:
+ position = 100 - cast(int, kwargs[ATTR_POSITION])
+
+ # Convert position to range of our entity.
+ if (
+ attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
+ ) is not None:
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = (position / 100) * (homee_max - homee_min) + homee_min
+
+ await self.async_set_value(attribute, homee_position)
+
+ async def async_stop_cover(self, **kwargs: Any) -> None:
+ """Stop the cover."""
+ if self._open_close_attribute is not None:
+ await self.async_set_value(self._open_close_attribute, 2)
+
+ async def async_open_cover_tilt(self, **kwargs: Any) -> None:
+ """Open the cover tilt."""
+ if (
+ slat_attribute := self._node.get_attribute_by_type(
+ AttributeType.SLAT_ROTATION_IMPULSE
+ )
+ ) is not None:
+ if not slat_attribute.is_reversed:
+ await self.async_set_value(slat_attribute, 2)
+ else:
+ await self.async_set_value(slat_attribute, 1)
+
+ async def async_close_cover_tilt(self, **kwargs: Any) -> None:
+ """Close the cover tilt."""
+ if (
+ slat_attribute := self._node.get_attribute_by_type(
+ AttributeType.SLAT_ROTATION_IMPULSE
+ )
+ ) is not None:
+ if not slat_attribute.is_reversed:
+ await self.async_set_value(slat_attribute, 1)
+ else:
+ await self.async_set_value(slat_attribute, 2)
+
+ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
+ """Move the cover tilt to a specific position."""
+ if CoverEntityFeature.SET_TILT_POSITION in self.supported_features:
+ position = 100 - cast(int, kwargs[ATTR_TILT_POSITION])
+
+ # Convert position to range of our entity.
+ if (
+ attribute := self._node.get_attribute_by_type(
+ AttributeType.SHUTTER_SLAT_POSITION
+ )
+ ) is not None:
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = (position / 100) * (homee_max - homee_min) + homee_min
+
+ await self.async_set_value(attribute, homee_position)
diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py
new file mode 100644
index 00000000000..50b67e582bb
--- /dev/null
+++ b/homeassistant/components/homee/entity.py
@@ -0,0 +1,147 @@
+"""Base Entities for Homee integration."""
+
+from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
+from pyHomee.model import HomeeAttribute, HomeeNode
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+from . import HomeeConfigEntry
+from .const import DOMAIN
+from .helpers import get_name_for_enum
+
+
+class HomeeEntity(Entity):
+ """Represents a Homee entity consisting of a single HomeeAttribute."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None:
+ """Initialize the wrapper using a HomeeAttribute and target entity."""
+ self._attribute = attribute
+ self._attr_unique_id = (
+ f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
+ )
+ self._entry = entry
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}")
+ }
+ )
+
+ self._host_connected = entry.runtime_data.connected
+
+ async def async_added_to_hass(self) -> None:
+ """Add the homee attribute entity to home assistant."""
+ self.async_on_remove(
+ self._attribute.add_on_changed_listener(self._on_node_updated)
+ )
+ self.async_on_remove(
+ self._entry.runtime_data.add_connection_listener(
+ self._on_connection_changed
+ )
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return the availability of the underlying node."""
+ return (self._attribute.state == AttributeState.NORMAL) and self._host_connected
+
+ async def async_update(self) -> None:
+ """Update entity from homee."""
+ homee = self._entry.runtime_data
+ await homee.update_attribute(self._attribute.node_id, self._attribute.id)
+
+ def _on_node_updated(self, attribute: HomeeAttribute) -> None:
+ self.schedule_update_ha_state()
+
+ def _on_connection_changed(self, connected: bool) -> None:
+ self._host_connected = connected
+ self.schedule_update_ha_state()
+
+
+class HomeeNodeEntity(Entity):
+ """Representation of an Entity that uses more than one HomeeAttribute."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ """Initialize the wrapper using a HomeeNode and target entity."""
+ self._node = node
+ self._attr_unique_id = f"{entry.unique_id}-{node.id}"
+ self._entry = entry
+
+ ## Homee hub itself has node-id -1
+ if node.id == -1:
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, entry.runtime_data.settings.uid)},
+ )
+ else:
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{entry.unique_id}-{node.id}")},
+ name=node.name,
+ model=get_name_for_enum(NodeProfile, node.profile),
+ sw_version=self._get_software_version(),
+ via_device=(DOMAIN, entry.runtime_data.settings.uid),
+ )
+
+ self._host_connected = entry.runtime_data.connected
+
+ async def async_added_to_hass(self) -> None:
+ """Add the homee binary sensor device to home assistant."""
+ self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated))
+ self.async_on_remove(
+ self._entry.runtime_data.add_connection_listener(
+ self._on_connection_changed
+ )
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return the availability of the underlying node."""
+ return self._node.state == NodeState.AVAILABLE and self._host_connected
+
+ async def async_update(self) -> None:
+ """Fetch new state data for this node."""
+ # Base class requests the whole node, if only a single attribute is needed
+ # the platform will overwrite this method.
+ homee = self._entry.runtime_data
+ await homee.update_node(self._node.id)
+
+ def _get_software_version(self) -> str | None:
+ """Return the software version of the node."""
+ if (
+ attribute := self._node.get_attribute_by_type(
+ AttributeType.FIRMWARE_REVISION
+ )
+ ) is not None:
+ return str(attribute.get_value())
+ if (
+ attribute := self._node.get_attribute_by_type(
+ AttributeType.SOFTWARE_REVISION
+ )
+ ) is not None:
+ return str(attribute.get_value())
+
+ return None
+
+ def has_attribute(self, attribute_type: AttributeType) -> bool:
+ """Check if an attribute of the given type exists."""
+ if self._node.attribute_map is None:
+ return False
+
+ return attribute_type in self._node.attribute_map
+
+ async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
+ """Set an attribute value on the homee node."""
+ homee = self._entry.runtime_data
+ await homee.set_value(attribute.node_id, attribute.id, value)
+
+ def _on_node_updated(self, node: HomeeNode) -> None:
+ self.schedule_update_ha_state()
+
+ def _on_connection_changed(self, connected: bool) -> None:
+ self._host_connected = connected
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py
new file mode 100644
index 00000000000..b73b1ae2bc9
--- /dev/null
+++ b/homeassistant/components/homee/helpers.py
@@ -0,0 +1,16 @@
+"""Helper functions for the homee custom component."""
+
+from enum import IntEnum
+import logging
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None:
+ """Return the enum item name for a given integer."""
+ try:
+ item = att_class(att_id)
+ except ValueError:
+ _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__)
+ return None
+ return item.name.lower()
diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json
new file mode 100644
index 00000000000..3b1ee17b89c
--- /dev/null
+++ b/homeassistant/components/homee/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "link_quality": {
+ "default": "mdi:signal"
+ },
+ "window_position": {
+ "default": "mdi:window-closed"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json
new file mode 100644
index 00000000000..d85ba25b6e7
--- /dev/null
+++ b/homeassistant/components/homee/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "homee",
+ "name": "Homee",
+ "codeowners": ["@Taraman17"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/homee",
+ "integration_type": "hub",
+ "iot_class": "local_push",
+ "loggers": ["homee"],
+ "quality_scale": "bronze",
+ "requirements": ["pyHomee==1.2.5"]
+}
diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml
new file mode 100644
index 00000000000..ff99d177018
--- /dev/null
+++ b/homeassistant/components/homee/quality_scale.yaml
@@ -0,0 +1,68 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: Integration is push based.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py
new file mode 100644
index 00000000000..9b8fb0f6fe1
--- /dev/null
+++ b/homeassistant/components/homee/sensor.py
@@ -0,0 +1,339 @@
+"""The homee sensor platform."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from pyHomee.const import AttributeType, NodeState
+from pyHomee.model import HomeeAttribute, HomeeNode
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import HomeeConfigEntry
+from .const import (
+ HOMEE_UNIT_TO_HA_UNIT,
+ OPEN_CLOSE_MAP,
+ OPEN_CLOSE_MAP_REVERSED,
+ WINDOW_MAP,
+ WINDOW_MAP_REVERSED,
+)
+from .entity import HomeeEntity, HomeeNodeEntity
+from .helpers import get_name_for_enum
+
+
+def get_open_close_value(attribute: HomeeAttribute) -> str | None:
+ """Return the open/close value."""
+ vals = OPEN_CLOSE_MAP if not attribute.is_reversed else OPEN_CLOSE_MAP_REVERSED
+ return vals.get(attribute.current_value)
+
+
+def get_window_value(attribute: HomeeAttribute) -> str | None:
+ """Return the states of a window open sensor."""
+ vals = WINDOW_MAP if not attribute.is_reversed else WINDOW_MAP_REVERSED
+ return vals.get(attribute.current_value)
+
+
+@dataclass(frozen=True, kw_only=True)
+class HomeeSensorEntityDescription(SensorEntityDescription):
+ """A class that describes Homee sensor entities."""
+
+ value_fn: Callable[[HomeeAttribute], str | float | None] = (
+ lambda value: value.current_value
+ )
+ native_unit_of_measurement_fn: Callable[[str], str | None] = (
+ lambda homee_unit: HOMEE_UNIT_TO_HA_UNIT[homee_unit]
+ )
+
+
+SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
+ AttributeType.ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription(
+ key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ AttributeType.BATTERY_LEVEL: HomeeSensorEntityDescription(
+ key="battery",
+ device_class=SensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.BRIGHTNESS: HomeeSensorEntityDescription(
+ key="brightness",
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=(
+ lambda attribute: attribute.current_value * 1000
+ if attribute.unit == "klx"
+ else attribute.current_value
+ ),
+ ),
+ AttributeType.CURRENT: HomeeSensorEntityDescription(
+ key="current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
+ key="power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription(
+ key="valve_position",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.DAWN: HomeeSensorEntityDescription(
+ key="dawn",
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.DEVICE_TEMPERATURE: HomeeSensorEntityDescription(
+ key="device_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.EXHAUST_MOTOR_REVS: HomeeSensorEntityDescription(
+ key="exhaust_motor_revs",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.INDOOR_RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
+ key="indoor_humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.INDOOR_TEMPERATURE: HomeeSensorEntityDescription(
+ key="indoor_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.INTAKE_MOTOR_REVS: HomeeSensorEntityDescription(
+ key="intake_motor_revs",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.LEVEL: HomeeSensorEntityDescription(
+ key="level",
+ device_class=SensorDeviceClass.VOLUME_STORAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.LINK_QUALITY: HomeeSensorEntityDescription(
+ key="link_quality",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.OPERATING_HOURS: HomeeSensorEntityDescription(
+ key="operating_hours",
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.OUTDOOR_RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
+ key="outdoor_humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.OUTDOOR_TEMPERATURE: HomeeSensorEntityDescription(
+ key="outdoor_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.POSITION: HomeeSensorEntityDescription(
+ key="position",
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.RAIN_FALL_LAST_HOUR: HomeeSensorEntityDescription(
+ key="rainfall_hour",
+ device_class=SensorDeviceClass.PRECIPITATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription(
+ key="rainfall_day",
+ device_class=SensorDeviceClass.PRECIPITATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
+ key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.TEMPERATURE: HomeeSensorEntityDescription(
+ key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.TOTAL_ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription(
+ key="total_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
+ key="total_current",
+ device_class=SensorDeviceClass.CURRENT,
+ ),
+ AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
+ key="total_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.TOTAL_VOLTAGE: HomeeSensorEntityDescription(
+ key="total_voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.UP_DOWN: HomeeSensorEntityDescription(
+ key="up_down",
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "open",
+ "closed",
+ "partial",
+ "opening",
+ "closing",
+ ],
+ value_fn=get_open_close_value,
+ ),
+ AttributeType.UV: HomeeSensorEntityDescription(
+ key="uv",
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.VOLTAGE: HomeeSensorEntityDescription(
+ key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.WIND_SPEED: HomeeSensorEntityDescription(
+ key="wind_speed",
+ device_class=SensorDeviceClass.WIND_SPEED,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ AttributeType.WINDOW_POSITION: HomeeSensorEntityDescription(
+ key="window_position",
+ device_class=SensorDeviceClass.ENUM,
+ options=["closed", "open", "tilted"],
+ value_fn=get_window_value,
+ ),
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class HomeeNodeSensorEntityDescription(SensorEntityDescription):
+ """Describes Homee node sensor entities."""
+
+ value_fn: Callable[[HomeeNode], str | None]
+
+
+NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
+ HomeeNodeSensorEntityDescription(
+ key="state",
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "available",
+ "unavailable",
+ "update_in_progress",
+ "waiting_for_attributes",
+ "initializing",
+ "user_interaction_required",
+ "password_required",
+ "host_unavailable",
+ "delete_in_progress",
+ "cosi_connected",
+ "blocked",
+ "waiting_for_wakeup",
+ "remote_node_deleted",
+ "firmware_update_in_progress",
+ ],
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ translation_key="node_sensor_state",
+ value_fn=lambda node: get_name_for_enum(NodeState, node.state),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_devices: AddEntitiesCallback,
+) -> None:
+ """Add the homee platform for the sensor components."""
+
+ devices: list[HomeeSensor | HomeeNodeSensor] = []
+ for node in config_entry.runtime_data.nodes:
+ # Node properties that are sensors.
+ devices.extend(
+ HomeeNodeSensor(node, config_entry, description)
+ for description in NODE_SENSOR_DESCRIPTIONS
+ )
+
+ # Node attributes that are sensors.
+ devices.extend(
+ HomeeSensor(attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type])
+ for attribute in node.attributes
+ if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable
+ )
+
+ if devices:
+ async_add_devices(devices)
+
+
+class HomeeSensor(HomeeEntity, SensorEntity):
+ """Representation of a homee sensor."""
+
+ entity_description: HomeeSensorEntityDescription
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: HomeeSensorEntityDescription,
+ ) -> None:
+ """Initialize a homee sensor entity."""
+ super().__init__(attribute, entry)
+ self.entity_description = description
+ self._attr_translation_key = description.key
+ if attribute.instance > 0:
+ self._attr_translation_key = f"{self._attr_translation_key}_instance"
+ self._attr_translation_placeholders = {"instance": str(attribute.instance)}
+
+ @property
+ def native_value(self) -> float | str | None:
+ """Return the native value of the sensor."""
+ return self.entity_description.value_fn(self._attribute)
+
+ @property
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the native unit of the sensor."""
+ return self.entity_description.native_unit_of_measurement_fn(
+ self._attribute.unit
+ )
+
+
+class HomeeNodeSensor(HomeeNodeEntity, SensorEntity):
+ """Represents a sensor based on a node's property."""
+
+ entity_description: HomeeNodeSensorEntityDescription
+
+ def __init__(
+ self,
+ node: HomeeNode,
+ entry: HomeeConfigEntry,
+ description: HomeeNodeSensorEntityDescription,
+ ) -> None:
+ """Initialize a homee node sensor entity."""
+ super().__init__(node, entry)
+ self.entity_description = description
+ self._node = node
+ self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
+
+ @property
+ def native_value(self) -> str | None:
+ """Return the sensors value."""
+ return self.entity_description.value_fn(self._node)
diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json
new file mode 100644
index 00000000000..401996622f2
--- /dev/null
+++ b/homeassistant/components/homee/strings.json
@@ -0,0 +1,140 @@
+{
+ "config": {
+ "flow_title": "Homee {name} ({host})",
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "user": {
+ "title": "Configure homee",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "username": "[%key:common::config_flow::data::username%]"
+ },
+ "data_description": {
+ "host": "The IP address of your Homee.",
+ "username": "The username for your Homee.",
+ "password": "The password for your Homee."
+ }
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "brightness_instance": {
+ "name": "Illuminance {instance}"
+ },
+ "current_instance": {
+ "name": "Current {instance}"
+ },
+ "dawn": {
+ "name": "Dawn"
+ },
+ "device_temperature": {
+ "name": "Device temperature"
+ },
+ "energy_instance": {
+ "name": "Energy {instance}"
+ },
+ "exhaust_motor_revs": {
+ "name": "Exhaust motor speed"
+ },
+ "indoor_humidity": {
+ "name": "Indoor humidity"
+ },
+ "indoor_humidity_instance": {
+ "name": "Indoor humidity {instance}"
+ },
+ "indoor_temperature": {
+ "name": "Indoor temperature"
+ },
+ "indoor_temperature_instance": {
+ "name": "Indoor temperature {instance}"
+ },
+ "intake_motor_revs": {
+ "name": "Intake motor speed"
+ },
+ "level": {
+ "name": "Level"
+ },
+ "link_quality": {
+ "name": "Link quality"
+ },
+ "node_state": {
+ "name": "Node state"
+ },
+ "operating_hours": {
+ "name": "Operating hours"
+ },
+ "outdoor_humidity": {
+ "name": "Outdoor humidity"
+ },
+ "outdoor_humidity_instance": {
+ "name": "Outdoor humidity {instance}"
+ },
+ "outdoor_temperature": {
+ "name": "Outdoor temperature"
+ },
+ "outdoor_temperature_instance": {
+ "name": "Outdoor temperature {instance}"
+ },
+ "position": {
+ "name": "Position"
+ },
+ "power_instance": {
+ "name": "Power {instance}"
+ },
+ "rainfall_hour": {
+ "name": "Rainfall last hour"
+ },
+ "rainfall_day": {
+ "name": "Rainfall today"
+ },
+ "total_current": {
+ "name": "Total current"
+ },
+ "total_energy": {
+ "name": "Total energy"
+ },
+ "total_power": {
+ "name": "Total power"
+ },
+ "total_voltage": {
+ "name": "Total voltage"
+ },
+ "up_down": {
+ "name": "State",
+ "state": {
+ "open": "[%key:common::state::open%]",
+ "closed": "[%key:common::state::closed%]",
+ "partial": "Partially open",
+ "opening": "Opening",
+ "closing": "Closing"
+ }
+ },
+ "uv": {
+ "name": "Ultraviolet"
+ },
+ "valve_position": {
+ "name": "Valve position"
+ },
+ "voltage_instance": {
+ "name": "Voltage {instance}"
+ },
+ "window_position": {
+ "name": "Window position",
+ "state": {
+ "closed": "[%key:common::state::closed%]",
+ "open": "[%key:common::state::open%]",
+ "tilted": "Tilted"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py
index d6daeb49f82..a477dde9c9d 100644
--- a/homeassistant/components/homekit/iidmanager.py
+++ b/homeassistant/components/homekit/iidmanager.py
@@ -102,8 +102,8 @@ class AccessoryIIDStorage:
char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None
# Allocation key must be a string since we are saving it to JSON
allocation_key = (
- f'{service_hap_type}_{service_unique_id or ""}_'
- f'{char_hap_type or ""}_{char_unique_id or ""}'
+ f"{service_hap_type}_{service_unique_id or ''}_"
+ f"{char_hap_type or ''}_{char_unique_id or ''}"
)
# AID must be a string since JSON keys cannot be int
aid_str = str(aid)
diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json
index cf74bcc7d67..d7ea293b5dc 100644
--- a/homeassistant/components/homekit/manifest.json
+++ b/homeassistant/components/homekit/manifest.json
@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==4.9.2",
- "fnv-hash-fast==1.0.2",
+ "fnv-hash-fast==1.2.2",
"PyQRCode==1.2.1",
"base36==0.1.1"
],
diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py
index 6752633f3d2..651033682cf 100644
--- a/homeassistant/components/homekit/type_covers.py
+++ b/homeassistant/components/homekit/type_covers.py
@@ -409,11 +409,8 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory):
"""Move cover to value if call came from HomeKit."""
_LOGGER.debug("%s: Set position to %d", self.entity_id, value)
- if (
- self._supports_stop
- and value > 70
- or not self._supports_stop
- and value >= 50
+ if (self._supports_stop and value > 70) or (
+ not self._supports_stop and value >= 50
):
service, position = (SERVICE_OPEN_COVER, 100)
elif value < 30 or not self._supports_stop:
diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py
index b958817bbac..f32c4f55a0f 100644
--- a/homeassistant/components/homekit/type_triggers.py
+++ b/homeassistant/components/homekit/type_triggers.py
@@ -48,7 +48,7 @@ class DeviceTriggerAccessory(HomeAccessory):
for idx, trigger in enumerate(device_triggers):
type_: str = trigger["type"]
subtype: str | None = trigger.get("subtype")
- unique_id = f'{type_}-{subtype or ""}'
+ unique_id = f"{type_}-{subtype or ''}"
entity_id: str | None = None
if (entity_id_or_uuid := trigger.get("entity_id")) and (
entry := ent_reg.async_get(entity_id_or_uuid)
@@ -122,7 +122,7 @@ class DeviceTriggerAccessory(HomeAccessory):
"""
reason = ""
if "trigger" in run_variables and "description" in run_variables["trigger"]:
- reason = f' by {run_variables["trigger"]["description"]}'
+ reason = f" by {run_variables['trigger']['description']}"
_LOGGER.debug("Button triggered%s - %s", reason, run_variables)
idx = int(run_variables["trigger"]["idx"])
self.triggers[idx].set_value(0)
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index 443b8b8a310..1181ceaa953 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -47,7 +47,7 @@ from homeassistant.core import (
callback,
split_entity_id,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -78,6 +78,7 @@ from .const import (
CONF_VIDEO_CODEC,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
+ CONF_VIDEO_PROFILE_NAMES,
DEFAULT_AUDIO_CODEC,
DEFAULT_AUDIO_MAP,
DEFAULT_AUDIO_PACKET_SIZE,
@@ -90,6 +91,7 @@ from .const import (
DEFAULT_VIDEO_CODEC,
DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE,
+ DEFAULT_VIDEO_PROFILE_NAMES,
DOMAIN,
FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE,
@@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
VALID_VIDEO_CODECS
),
+ vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [
+ cv.string
+ ],
vol.Optional(
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
): cv.positive_int,
@@ -643,7 +648,8 @@ def state_needs_accessory_mode(state: State) -> bool:
state.domain == MEDIA_PLAYER_DOMAIN
and state.attributes.get(ATTR_DEVICE_CLASS)
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
- or state.domain == REMOTE_DOMAIN
+ ) or (
+ state.domain == REMOTE_DOMAIN
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
& RemoteEntityFeature.ACTIVITY
)
diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py
index ba5237e6e2d..cbf4ad61c2f 100644
--- a/homeassistant/components/homekit_controller/climate.py
+++ b/homeassistant/components/homekit_controller/climate.py
@@ -17,7 +17,7 @@ from aiohomekit.model.characteristics import (
)
from aiohomekit.model.services import Service, ServicesTypes
from aiohomekit.utils import clamp_enum_to_char
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py
index 9e67d618079..0acf57fe55b 100644
--- a/homeassistant/components/homekit_controller/config_flow.py
+++ b/homeassistant/components/homekit_controller/config_flow.py
@@ -19,11 +19,14 @@ from aiohomekit.model.status_flags import StatusFlags
from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo,
+)
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, KNOWN_DEVICES
@@ -189,7 +192,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
return False
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered HomeKit accessory.
@@ -202,7 +205,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
key.lower(): value for (key, value) in discovery_info.properties.items()
}
- if zeroconf.ATTR_PROPERTIES_ID not in properties:
+ if ATTR_PROPERTIES_ID not in properties:
# This can happen if the TXT record is received after the PTR record
# we will wait for the next update in this case
_LOGGER.debug(
@@ -216,7 +219,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
# The hkid is a unique random number that looks like a pairing code.
# It changes if a device is factory reset.
- hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID]
+ hkid: str = properties[ATTR_PROPERTIES_ID]
normalized_hkid = normalize_hkid(hkid)
upper_case_hkid = hkid.upper()
status_flags = int(properties["sf"])
diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py
index 52f22bcc9f4..211aec2c2d5 100644
--- a/homeassistant/components/homekit_controller/connection.py
+++ b/homeassistant/components/homekit_controller/connection.py
@@ -323,8 +323,7 @@ class HKDevice:
self.hass,
self.async_update_available_state,
timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL),
- name=f"HomeKit Device {self.unique_id} BLE availability "
- "check poll",
+ name=f"HomeKit Device {self.unique_id} BLE availability check poll",
)
)
# BLE devices always get an RSSI sensor as well
diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py
index d7480a40a93..4fff32002e2 100644
--- a/homeassistant/components/homekit_controller/cover.py
+++ b/homeassistant/components/homekit_controller/cover.py
@@ -6,7 +6,7 @@ from typing import Any
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import Service, ServicesTypes
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.cover import (
ATTR_POSITION,
diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py
index 2ae534099ae..b7f1842392b 100644
--- a/homeassistant/components/homekit_controller/fan.py
+++ b/homeassistant/components/homekit_controller/fan.py
@@ -6,7 +6,7 @@ from typing import Any
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import Service, ServicesTypes
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.fan import (
DIRECTION_FORWARD,
diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py
index f82baab5df7..b2b0e0b1026 100644
--- a/homeassistant/components/homekit_controller/humidifier.py
+++ b/homeassistant/components/homekit_controller/humidifier.py
@@ -6,7 +6,7 @@ from typing import Any
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import Service, ServicesTypes
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.humidifier import (
DEFAULT_MAX_HUMIDITY,
diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py
index 26f10768aa0..04c75731731 100644
--- a/homeassistant/components/homekit_controller/light.py
+++ b/homeassistant/components/homekit_controller/light.py
@@ -6,7 +6,7 @@ from typing import Any
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import Service, ServicesTypes
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import KNOWN_DEVICES
from .connection import HKDevice
diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py
index f0fc2a40278..710f2ede52b 100644
--- a/homeassistant/components/homematic/__init__.py
+++ b/homeassistant/components/homematic/__init__.py
@@ -24,8 +24,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import (
diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py
index ac0a05d24c1..5a5b2a3b8c8 100644
--- a/homeassistant/components/homematic/entity.py
+++ b/homeassistant/components/homematic/entity.py
@@ -10,7 +10,7 @@ from pyhomematic import HMConnection
from pyhomematic.devicetypes.generic import HMGeneric
from homeassistant.const import ATTR_NAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.event import track_time_interval
diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py
index ced8ea6a951..1f89abea5cc 100644
--- a/homeassistant/components/homematic/notify.py
+++ b/homeassistant/components/homematic/notify.py
@@ -10,8 +10,7 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.template as template_helper
+from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json
index 48ebbe5d345..d962a218a4f 100644
--- a/homeassistant/components/homematic/strings.json
+++ b/homeassistant/components/homematic/strings.json
@@ -6,7 +6,7 @@
"fields": {
"address": {
"name": "Address",
- "description": "Address of homematic device or BidCoS-RF for virtual remote."
+ "description": "Address of Homematic device or BidCoS-RF for virtual remote."
},
"channel": {
"name": "Channel",
@@ -28,7 +28,7 @@
"fields": {
"entity_id": {
"name": "Entity",
- "description": "Name(s) of homematic central to set value."
+ "description": "Name(s) of Homematic central to set value."
},
"name": {
"name": "[%key:common::config_flow::data::name%]",
@@ -72,11 +72,11 @@
},
"reconnect": {
"name": "Reconnect",
- "description": "Reconnects to all Homematic Hubs."
+ "description": "Reconnects to all Homematic hubs."
},
"set_install_mode": {
"name": "Set install mode",
- "description": "Set a RPC XML interface into installation mode.",
+ "description": "Sets a RPC XML interface into installation mode.",
"fields": {
"interface": {
"name": "Interface",
@@ -92,7 +92,7 @@
},
"address": {
"name": "Address",
- "description": "Address of homematic device or BidCoS-RF to learn."
+ "description": "Address of Homematic device or BidCoS-RF to learn."
}
}
},
diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py
index bba67e10d4c..2b72794b323 100644
--- a/homeassistant/components/homematicip_cloud/const.py
+++ b/homeassistant/components/homematicip_cloud/const.py
@@ -14,6 +14,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
+ Platform.EVENT,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py
new file mode 100644
index 00000000000..8fb558b2b34
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/event.py
@@ -0,0 +1,94 @@
+"""Support for HomematicIP Cloud events."""
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from homematicip.aio.device import Device
+
+from homeassistant.components.event import (
+ EventDeviceClass,
+ EventEntity,
+ EventEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .entity import HomematicipGenericEntity
+from .hap import HomematicipHAP
+
+
+@dataclass(frozen=True, kw_only=True)
+class HmipEventEntityDescription(EventEntityDescription):
+ """Description of a HomematicIP Cloud event."""
+
+
+EVENT_DESCRIPTIONS = {
+ "doorbell": HmipEventEntityDescription(
+ key="doorbell",
+ translation_key="doorbell",
+ device_class=EventDeviceClass.DOORBELL,
+ event_types=["ring"],
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the HomematicIP cover from a config entry."""
+ hap = hass.data[DOMAIN][config_entry.unique_id]
+
+ async_add_entities(
+ HomematicipDoorBellEvent(
+ hap,
+ device,
+ channel.index,
+ EVENT_DESCRIPTIONS["doorbell"],
+ )
+ for device in hap.home.devices
+ for channel in device.functionalChannels
+ if channel.channelRole == "DOOR_BELL_INPUT"
+ )
+
+
+class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
+ """Event class for HomematicIP doorbell events."""
+
+ _attr_device_class = EventDeviceClass.DOORBELL
+
+ def __init__(
+ self,
+ hap: HomematicipHAP,
+ device: Device,
+ channel: int,
+ description: HmipEventEntityDescription,
+ ) -> None:
+ """Initialize the event."""
+ super().__init__(
+ hap,
+ device,
+ post=description.key,
+ channel=channel,
+ is_multi_channel=False,
+ )
+
+ self.entity_description = description
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ await super().async_added_to_hass()
+ self.functional_channel.add_on_channel_event_handler(self._async_handle_event)
+
+ @callback
+ def _async_handle_event(self, *args, **kwargs) -> None:
+ """Handle the event fired by the functional channel."""
+ event_types = self.entity_description.event_types
+ if TYPE_CHECKING:
+ assert event_types is not None
+
+ self._trigger_event(event_type=event_types[0])
+ self.async_write_ha_state()
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index a44d0586952..414ba37709e 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
- "requirements": ["homematicip==1.1.5"]
+ "requirements": ["homematicip==1.1.7"]
}
diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py
index c44d280c190..9ed9b33d7c7 100644
--- a/homeassistant/components/homematicip_cloud/sensor.py
+++ b/homeassistant/components/homematicip_cloud/sensor.py
@@ -93,7 +93,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
}
-async def async_setup_entry( # noqa: C901
+async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py
index 69765ccc601..7a4dfd4916f 100644
--- a/homeassistant/components/homematicip_cloud/services.py
+++ b/homeassistant/components/homematicip_cloud/services.py
@@ -14,7 +14,7 @@ import voluptuous as vol
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 import config_validation as cv
from homeassistant.helpers.config_validation import comp_entity_ids
from homeassistant.helpers.service import (
async_register_admin_service,
diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json
index ac7b184e513..37deace7ebf 100644
--- a/homeassistant/components/homematicip_cloud/strings.json
+++ b/homeassistant/components/homematicip_cloud/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"init": {
- "title": "Pick HomematicIP Access point",
+ "title": "Pick Homematic IP access point",
"data": {
"hapid": "Access point ID (SGTIN)",
"pin": "[%key:common::config_flow::data::pin%]",
@@ -10,8 +10,8 @@
}
},
"link": {
- "title": "Link Access point",
- "description": "Press the blue button on the access point and the **Submit** button to register HomematicIP with Home Assistant.\n\n"
+ "title": "Link access point",
+ "description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n"
}
},
"error": {
@@ -28,7 +28,7 @@
},
"exceptions": {
"access_point_not_found": {
- "message": "No matching access point found for access point id {id}"
+ "message": "No matching access point found for access point ID {id}"
}
},
"services": {
@@ -41,8 +41,8 @@
"description": "The duration of eco mode in minutes."
},
"accesspoint_id": {
- "name": "Accesspoint ID",
- "description": "The ID of the Homematic IP Access Point."
+ "name": "Access point ID",
+ "description": "The ID of the Homematic IP access point."
}
}
},
@@ -113,20 +113,20 @@
}
},
"dump_hap_config": {
- "name": "Dump hap config",
- "description": "Dumps the configuration of the Homematic IP Access Point(s).",
+ "name": "Dump HAP config",
+ "description": "Dumps the configuration of the Homematic IP access point(s).",
"fields": {
"config_output_path": {
"name": "Config output path",
- "description": "(Default is 'Your home-assistant config directory') Path where to store the config."
+ "description": "Path where to store the config. Default is 'Your Home Assistant config directory'."
},
"config_output_file_prefix": {
"name": "Config output file prefix",
- "description": "Name of the config file. The SGTIN of the AP will always be appended."
+ "description": "Name of the config file. The SGTIN of the HAP will always be appended."
},
"anonymize": {
"name": "Anonymize",
- "description": "Should the Configuration be anonymized?"
+ "description": "Should the configuration be anonymized?"
}
}
},
@@ -142,7 +142,7 @@
},
"set_home_cooling_mode": {
"name": "Set home cooling mode",
- "description": "Set the heating/cooling mode for the entire home",
+ "description": "Sets 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%]",
diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py
index 4733bc67073..36c9681dcd2 100644
--- a/homeassistant/components/homewizard/__init__.py
+++ b/homeassistant/components/homewizard/__init__.py
@@ -1,8 +1,18 @@
"""The Homewizard integration."""
+from homewizard_energy import (
+ HomeWizardEnergy,
+ HomeWizardEnergyV1,
+ HomeWizardEnergyV2,
+ has_v2_api,
+)
+
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
+from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, PLATFORMS
from .coordinator import HWEnergyDeviceUpdateCoordinator
@@ -12,7 +22,27 @@ type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool:
"""Set up Homewizard from a config entry."""
- coordinator = HWEnergyDeviceUpdateCoordinator(hass)
+
+ api: HomeWizardEnergy
+
+ is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False
+
+ if (token := entry.data.get(CONF_TOKEN)) and is_battery:
+ api = HomeWizardEnergyV2(
+ entry.data[CONF_IP_ADDRESS],
+ token=token,
+ clientsession=async_get_clientsession(hass),
+ )
+ else:
+ api = HomeWizardEnergyV1(
+ entry.data[CONF_IP_ADDRESS],
+ clientsession=async_get_clientsession(hass),
+ )
+
+ if is_battery:
+ await async_check_v2_support_and_create_issue(hass, entry)
+
+ coordinator = HWEnergyDeviceUpdateCoordinator(hass, api)
try:
await coordinator.async_config_entry_first_refresh()
@@ -44,3 +74,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_check_v2_support_and_create_issue(
+ hass: HomeAssistant, entry: HomeWizardConfigEntry
+) -> None:
+ """Check if the device supports v2 and create an issue if not."""
+
+ if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)):
+ return
+
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"migrate_to_v2_api_{entry.entry_id}",
+ is_fixable=True,
+ is_persistent=False,
+ learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device",
+ translation_key="migrate_to_v2_api",
+ translation_placeholders={
+ "title": entry.title,
+ },
+ severity=IssueSeverity.WARNING,
+ data={"entry_id": entry.entry_id},
+ )
diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py
index 7b05cb95271..b86f797ec2d 100644
--- a/homeassistant/components/homewizard/button.py
+++ b/homeassistant/components/homewizard/button.py
@@ -19,7 +19,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Identify button."""
- if entry.runtime_data.supports_identify():
+ if entry.runtime_data.data.device.supports_identify():
async_add_entities([HomeWizardIdentifyButton(entry.runtime_data)])
diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py
index a6e4356328e..6bcc51f939e 100644
--- a/homeassistant/components/homewizard/config_flow.py
+++ b/homeassistant/components/homewizard/config_flow.py
@@ -3,40 +3,33 @@
from __future__ import annotations
from collections.abc import Mapping
-import logging
-from typing import Any, NamedTuple
+from typing import Any
-from homewizard_energy import HomeWizardEnergyV1
-from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError
-from homewizard_energy.v1.models import Device
+from homewizard_energy import (
+ HomeWizardEnergy,
+ HomeWizardEnergyV1,
+ HomeWizardEnergyV2,
+ has_v2_api,
+)
+from homewizard_energy.errors import (
+ DisabledError,
+ RequestError,
+ UnauthorizedError,
+ UnsupportedError,
+)
+from homewizard_energy.models import Device
import voluptuous as vol
-from homeassistant.components import onboarding, zeroconf
-from homeassistant.components.dhcp import DhcpServiceInfo
+from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH
+from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import TextSelector
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
-from .const import (
- CONF_API_ENABLED,
- CONF_PRODUCT_NAME,
- CONF_PRODUCT_TYPE,
- CONF_SERIAL,
- DOMAIN,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class DiscoveryData(NamedTuple):
- """User metadata."""
-
- ip: str
- product_name: str
- product_type: str
- serial: str
+from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER
class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -44,7 +37,10 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- discovery: DiscoveryData
+ ip_address: str | None = None
+ product_name: str | None = None
+ product_type: str | None = None
+ serial: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -53,10 +49,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = None
if user_input is not None:
try:
- device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS])
+ device_info = await async_try_connect(user_input[CONF_IP_ADDRESS])
except RecoverableError as ex:
- _LOGGER.error(ex)
+ LOGGER.error(ex)
errors = {"base": ex.error_code}
+ except UnauthorizedError:
+ # Device responded, so IP is correct. But we have to authorize
+ self.ip_address = user_input[CONF_IP_ADDRESS]
+ return await self.async_step_authorize()
else:
await self.async_set_unique_id(
f"{device_info.product_type}_{device_info.serial}"
@@ -80,32 +80,60 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_authorize(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Step where we attempt to get a token."""
+ assert self.ip_address
+
+ # Tell device we want a token, user must now press the button within 30 seconds
+ # The first attempt will always fail, but this opens the window to press the button
+ token = await async_request_token(self.ip_address)
+ errors: dict[str, str] | None = None
+
+ if token is None:
+ if user_input is not None:
+ errors = {"base": "authorization_failed"}
+
+ return self.async_show_form(step_id="authorize", errors=errors)
+
+ # Now we got a token, we can ask for some more info
+
+ async with HomeWizardEnergyV2(self.ip_address, token=token) as api:
+ device_info = await api.device()
+
+ data = {
+ CONF_IP_ADDRESS: self.ip_address,
+ CONF_TOKEN: token,
+ }
+
+ await self.async_set_unique_id(
+ f"{device_info.product_type}_{device_info.serial}"
+ )
+ self._abort_if_unique_id_configured(updates=data)
+ return self.async_create_entry(
+ title=f"{device_info.product_name}",
+ data=data,
+ )
+
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
+
if (
- CONF_API_ENABLED not in discovery_info.properties
- or CONF_PATH not in discovery_info.properties
- or CONF_PRODUCT_NAME not in discovery_info.properties
+ CONF_PRODUCT_NAME not in discovery_info.properties
or CONF_PRODUCT_TYPE not in discovery_info.properties
or CONF_SERIAL not in discovery_info.properties
):
return self.async_abort(reason="invalid_discovery_parameters")
- if (discovery_info.properties[CONF_PATH]) != "/api/v1":
- return self.async_abort(reason="unsupported_api_version")
+ self.ip_address = discovery_info.host
+ self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE]
+ self.product_name = discovery_info.properties[CONF_PRODUCT_NAME]
+ self.serial = discovery_info.properties[CONF_SERIAL]
- self.discovery = DiscoveryData(
- ip=discovery_info.host,
- product_type=discovery_info.properties[CONF_PRODUCT_TYPE],
- product_name=discovery_info.properties[CONF_PRODUCT_NAME],
- serial=discovery_info.properties[CONF_SERIAL],
- )
-
- await self.async_set_unique_id(
- f"{self.discovery.product_type}_{self.discovery.serial}"
- )
+ await self.async_set_unique_id(f"{self.product_type}_{self.serial}")
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: discovery_info.host}
)
@@ -120,10 +148,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
This flow is triggered only by DHCP discovery of known devices.
"""
try:
- device = await self._async_try_connect(discovery_info.ip)
+ device = await async_try_connect(discovery_info.ip)
except RecoverableError as ex:
- _LOGGER.error(ex)
+ LOGGER.error(ex)
return self.async_abort(reason="unknown")
+ except UnauthorizedError:
+ return self.async_abort(reason="unsupported_api_version")
await self.async_set_unique_id(
f"{device.product_type}_{discovery_info.macaddress}"
@@ -142,34 +172,41 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
+ assert self.ip_address
+ assert self.product_name
+ assert self.product_type
+ assert self.serial
+
errors: dict[str, str] | None = None
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
try:
- await self._async_try_connect(self.discovery.ip)
+ await async_try_connect(self.ip_address)
except RecoverableError as ex:
- _LOGGER.error(ex)
+ LOGGER.error(ex)
errors = {"base": ex.error_code}
+ except UnauthorizedError:
+ return await self.async_step_authorize()
else:
return self.async_create_entry(
- title=self.discovery.product_name,
- data={CONF_IP_ADDRESS: self.discovery.ip},
+ title=self.product_name,
+ data={CONF_IP_ADDRESS: self.ip_address},
)
self._set_confirm_only()
# We won't be adding mac/serial to the title for devices
# that users generally don't have multiple of.
- name = self.discovery.product_name
- if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]:
- name = f"{name} ({self.discovery.serial})"
+ name = self.product_name
+ if self.product_type not in ["HWE-P1", "HWE-WTR"]:
+ name = f"{name} ({self.serial})"
self.context["title_placeholders"] = {"name": name}
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={
- CONF_PRODUCT_TYPE: self.discovery.product_type,
- CONF_SERIAL: self.discovery.serial,
- CONF_IP_ADDRESS: self.discovery.ip,
+ CONF_PRODUCT_TYPE: self.product_type,
+ CONF_SERIAL: self.serial,
+ CONF_IP_ADDRESS: self.ip_address,
},
errors=errors,
)
@@ -178,36 +215,74 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-auth if API was disabled."""
- return await self.async_step_reauth_confirm()
+ self.ip_address = entry_data[CONF_IP_ADDRESS]
- async def async_step_reauth_confirm(
+ # If token exists, we assume we use the v2 API and that the token has been invalidated
+ if entry_data.get(CONF_TOKEN):
+ return await self.async_step_reauth_confirm_update_token()
+
+ # Else we assume we use the v1 API and that the API has been disabled
+ return await self.async_step_reauth_enable_api()
+
+ async def async_step_reauth_enable_api(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Confirm reauth dialog."""
+ """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API."""
errors: dict[str, str] | None = None
if user_input is not None:
reauth_entry = self._get_reauth_entry()
try:
- await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS])
+ await async_try_connect(reauth_entry.data[CONF_IP_ADDRESS])
except RecoverableError as ex:
- _LOGGER.error(ex)
+ LOGGER.error(ex)
errors = {"base": ex.error_code}
else:
await self.hass.config_entries.async_reload(reauth_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
+ return self.async_abort(reason="reauth_enable_api_successful")
- return self.async_show_form(step_id="reauth_confirm", errors=errors)
+ return self.async_show_form(step_id="reauth_enable_api", errors=errors)
+
+ async def async_step_reauth_confirm_update_token(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ assert self.ip_address
+
+ errors: dict[str, str] | None = None
+
+ token = await async_request_token(self.ip_address)
+
+ if user_input is not None:
+ if token is None:
+ errors = {"base": "authorization_failed"}
+ else:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates={
+ CONF_TOKEN: token,
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm_update_token", errors=errors
+ )
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+
if user_input:
try:
- device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS])
+ device_info = await async_try_connect(
+ user_input[CONF_IP_ADDRESS],
+ token=reconfigure_entry.data.get(CONF_TOKEN),
+ )
+
except RecoverableError as ex:
- _LOGGER.error(ex)
+ LOGGER.error(ex)
errors = {"base": ex.error_code}
else:
await self.async_set_unique_id(
@@ -218,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self._get_reconfigure_entry(),
data_updates=user_input,
)
- reconfigure_entry = self._get_reconfigure_entry()
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
@@ -235,37 +309,65 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- @staticmethod
- async def _async_try_connect(ip_address: str) -> Device:
- """Try to connect.
- Make connection with device to test the connection
- and to get info for unique_id.
- """
+async def async_try_connect(ip_address: str, token: str | None = None) -> Device:
+ """Try to connect.
+
+ Make connection with device to test the connection
+ and to get info for unique_id.
+ """
+
+ energy_api: HomeWizardEnergy
+
+ # Determine if device is v1 or v2 capable
+ if await has_v2_api(ip_address):
+ energy_api = HomeWizardEnergyV2(ip_address, token=token)
+ else:
energy_api = HomeWizardEnergyV1(ip_address)
- try:
- return await energy_api.device()
- except DisabledError as ex:
- raise RecoverableError(
- "API disabled, API must be enabled in the app", "api_not_enabled"
- ) from ex
+ try:
+ return await energy_api.device()
- except UnsupportedError as ex:
- _LOGGER.error("API version unsuppored")
- raise AbortFlow("unsupported_api_version") from ex
+ except DisabledError as ex:
+ raise RecoverableError(
+ "API disabled, API must be enabled in the app", "api_not_enabled"
+ ) from ex
- except RequestError as ex:
- raise RecoverableError(
- "Device unreachable or unexpected response", "network_error"
- ) from ex
+ except UnsupportedError as ex:
+ LOGGER.error("API version unsuppored")
+ raise AbortFlow("unsupported_api_version") from ex
- except Exception as ex:
- _LOGGER.exception("Unexpected exception")
- raise AbortFlow("unknown_error") from ex
+ except RequestError as ex:
+ raise RecoverableError(
+ "Device unreachable or unexpected response", "network_error"
+ ) from ex
- finally:
- await energy_api.close()
+ except UnauthorizedError as ex:
+ raise UnauthorizedError("Unauthorized") from ex
+
+ except Exception as ex:
+ LOGGER.exception("Unexpected exception")
+ raise AbortFlow("unknown_error") from ex
+
+ finally:
+ await energy_api.close()
+
+
+async def async_request_token(ip_address: str) -> str | None:
+ """Try to request a token from the device.
+
+ This method is used to request a token from the device,
+ it will return None if the token request failed.
+ """
+
+ api = HomeWizardEnergyV2(ip_address)
+
+ try:
+ return await api.get_token("home-assistant")
+ except DisabledError:
+ return None
+ finally:
+ await api.close()
class RecoverableError(HomeAssistantError):
diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py
index 809ecc1416b..e0448edaf86 100644
--- a/homeassistant/components/homewizard/const.py
+++ b/homeassistant/components/homewizard/const.py
@@ -2,12 +2,9 @@
from __future__ import annotations
-from dataclasses import dataclass
from datetime import timedelta
import logging
-from homewizard_energy.v1.models import Data, Device, State, System
-
from homeassistant.const import Platform
DOMAIN = "homewizard"
@@ -16,20 +13,8 @@ PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
LOGGER = logging.getLogger(__package__)
# Platform config.
-CONF_API_ENABLED = "api_enabled"
-CONF_DATA = "data"
CONF_PRODUCT_NAME = "product_name"
CONF_PRODUCT_TYPE = "product_type"
CONF_SERIAL = "serial"
UPDATE_INTERVAL = timedelta(seconds=5)
-
-
-@dataclass
-class DeviceResponseEntry:
- """Dict describing a single response entry."""
-
- device: Device
- data: Data
- state: State | None = None
- system: System | None = None
diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py
index 8f5045d3b94..92beb99ad2c 100644
--- a/homeassistant/components/homewizard/coordinator.py
+++ b/homeassistant/components/homewizard/coordinator.py
@@ -2,68 +2,35 @@
from __future__ import annotations
-import logging
-
-from homewizard_energy import HomeWizardEnergyV1
-from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError
-from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE
-from homewizard_energy.v1.models import Device
+from homewizard_energy import HomeWizardEnergy
+from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError
+from homewizard_energy.models import CombinedModels as DeviceResponseEntry
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DOMAIN, UPDATE_INTERVAL, DeviceResponseEntry
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]):
"""Gather data for the energy device."""
- api: HomeWizardEnergyV1
+ api: HomeWizardEnergy
api_disabled: bool = False
- _unsupported_error: bool = False
-
config_entry: ConfigEntry
- def __init__(
- self,
- hass: HomeAssistant,
- ) -> None:
+ def __init__(self, hass: HomeAssistant, api: HomeWizardEnergy) -> None:
"""Initialize update coordinator."""
- super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
- self.api = HomeWizardEnergyV1(
- self.config_entry.data[CONF_IP_ADDRESS],
- clientsession=async_get_clientsession(hass),
- )
+ super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
+ self.api = api
async def _async_update_data(self) -> DeviceResponseEntry:
"""Fetch all device and sensor data from api."""
try:
- data = DeviceResponseEntry(
- device=await self.api.device(),
- data=await self.api.data(),
- )
-
- try:
- if self.supports_state(data.device):
- data.state = await self.api.state()
-
- data.system = await self.api.system()
-
- except UnsupportedError as ex:
- # Old firmware, ignore
- if not self._unsupported_error:
- self._unsupported_error = True
- _LOGGER.warning(
- "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device",
- self.config_entry.title,
- ex,
- )
+ data = await self.api.combined()
except RequestError as ex:
raise UpdateFailed(
@@ -85,22 +52,10 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
ex, translation_domain=DOMAIN, translation_key="api_disabled"
) from ex
+ except UnauthorizedError as ex:
+ raise ConfigEntryAuthFailed from ex
+
self.api_disabled = False
self.data = data
return data
-
- def supports_state(self, device: Device | None = None) -> bool:
- """Return True if the device supports state."""
-
- if device is None:
- device = self.data.device
-
- return device.product_type in SUPPORTS_STATE
-
- def supports_identify(self, device: Device | None = None) -> bool:
- """Return True if the device supports identify."""
- if device is None:
- device = self.data.device
-
- return device.product_type in SUPPORTS_IDENTIFY
diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py
index 128e70d276a..12bd25671e0 100644
--- a/homeassistant/components/homewizard/diagnostics.py
+++ b/homeassistant/components/homewizard/diagnostics.py
@@ -13,11 +13,13 @@ from . import HomeWizardConfigEntry
TO_REDACT = {
CONF_IP_ADDRESS,
- "serial",
- "wifi_ssid",
- "unique_meter_id",
- "unique_id",
"gas_unique_id",
+ "id",
+ "serial",
+ "token",
+ "unique_id",
+ "unique_meter_id",
+ "wifi_ssid",
}
@@ -27,23 +29,10 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
data = entry.runtime_data.data
- state: dict[str, Any] | None = None
- if data.state:
- state = asdict(data.state)
-
- system: dict[str, Any] | None = None
- if data.system:
- system = asdict(data.system)
-
return async_redact_data(
{
"entry": async_redact_data(entry.data, TO_REDACT),
- "data": {
- "device": asdict(data.device),
- "data": asdict(data.data),
- "state": state,
- "system": system,
- },
+ "data": asdict(data),
},
TO_REDACT,
)
diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py
index 0aea899c044..1090f561838 100644
--- a/homeassistant/components/homewizard/entity.py
+++ b/homeassistant/components/homewizard/entity.py
@@ -22,9 +22,7 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]):
manufacturer="HomeWizard",
sw_version=coordinator.data.device.firmware_version,
model_id=coordinator.data.device.product_type,
- model=coordinator.data.device.product.name
- if coordinator.data.device.product
- else None,
+ model=coordinator.data.device.model_name,
)
if (serial_number := coordinator.data.device.serial) is not None:
diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json
index e6b1a34841f..68ebd6b84d0 100644
--- a/homeassistant/components/homewizard/icons.json
+++ b/homeassistant/components/homewizard/icons.json
@@ -15,6 +15,9 @@
"any_power_fail_count": {
"default": "mdi:transmission-tower-off"
},
+ "cycles": {
+ "default": "mdi:battery-sync-outline"
+ },
"dsmr_version": {
"default": "mdi:counter"
},
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index 83937809b60..51a315b2286 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -12,6 +12,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
- "requirements": ["python-homewizard-energy==v7.0.1"],
- "zeroconf": ["_hwenergy._tcp.local."]
+ "requirements": ["python-homewizard-energy==v8.3.2"],
+ "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py
index 1ed4c642f6b..5806295fc81 100644
--- a/homeassistant/components/homewizard/number.py
+++ b/homeassistant/components/homewizard/number.py
@@ -6,7 +6,6 @@ from homeassistant.components.number import NumberEntity
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.color import brightness_to_value, value_to_brightness
from . import HomeWizardConfigEntry
from .coordinator import HWEnergyDeviceUpdateCoordinator
@@ -22,7 +21,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up numbers for device."""
- if entry.runtime_data.supports_state():
+ if entry.runtime_data.data.device.supports_state():
async_add_entities([HWEnergyNumberEntity(entry.runtime_data)])
@@ -46,22 +45,21 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity):
@homewizard_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set a new value."""
- await self.coordinator.api.state_set(
- brightness=value_to_brightness((0, 100), value)
- )
+ await self.coordinator.api.system(status_led_brightness_pct=int(value))
await self.coordinator.async_refresh()
@property
def available(self) -> bool:
"""Return if entity is available."""
- return super().available and self.coordinator.data.state is not None
+ return super().available and self.coordinator.data.system is not None
@property
def native_value(self) -> float | None:
"""Return the current value."""
if (
- not self.coordinator.data.state
- or (brightness := self.coordinator.data.state.brightness) is None
+ not self.coordinator.data.system
+ or (brightness := self.coordinator.data.system.status_led_brightness_pct)
+ is None
):
return None
- return round(brightness_to_value((0, 100), brightness))
+ return round(brightness)
diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml
index 423bc4dea49..008772a5a29 100644
--- a/homeassistant/components/homewizard/quality_scale.yaml
+++ b/homeassistant/components/homewizard/quality_scale.yaml
@@ -47,7 +47,10 @@ rules:
devices: done
diagnostics: done
discovery-update-info: done
- discovery: done
+ discovery:
+ status: done
+ comment: |
+ DHCP IP address updates are not supported for the v2 API.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
@@ -66,10 +69,7 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues:
- status: exempt
- comment: |
- This integration does not raise any repairable issues.
+ repair-issues: done
stale-devices:
status: exempt
comment: |
diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py
new file mode 100644
index 00000000000..4c9a03b493f
--- /dev/null
+++ b/homeassistant/components/homewizard/repairs.py
@@ -0,0 +1,79 @@
+"""Repairs for HomeWizard integration."""
+
+from __future__ import annotations
+
+from homeassistant import data_entry_flow
+from homeassistant.components.repairs import RepairsFlow
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResult
+
+from .config_flow import async_request_token
+
+
+class MigrateToV2ApiRepairFlow(RepairsFlow):
+ """Handler for an issue fixing flow."""
+
+ def __init__(self, entry: ConfigEntry) -> None:
+ """Create flow."""
+ self.entry = entry
+
+ 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
+ ) -> FlowResult:
+ """Handle the confirm step of a fix flow."""
+
+ if user_input is not None:
+ return await self.async_step_authorize()
+
+ return self.async_show_form(
+ step_id="confirm", description_placeholders={"title": self.entry.title}
+ )
+
+ async def async_step_authorize(
+ self, user_input: dict[str, str] | None = None
+ ) -> FlowResult:
+ """Handle the authorize step of a fix flow."""
+
+ ip_address = self.entry.data[CONF_IP_ADDRESS]
+
+ # Tell device we want a token, user must now press the button within 30 seconds
+ # The first attempt will always fail, but this opens the window to press the button
+ token = await async_request_token(ip_address)
+ errors: dict[str, str] | None = None
+
+ if token is None:
+ if user_input is not None:
+ errors = {"base": "authorization_failed"}
+
+ return self.async_show_form(step_id="authorize", errors=errors)
+
+ data = {**self.entry.data, CONF_TOKEN: token}
+ self.hass.config_entries.async_update_entry(self.entry, data=data)
+ 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, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ assert data is not None
+ assert isinstance(data["entry_id"], str)
+
+ if issue_id.startswith("migrate_to_v2_api_") and (
+ entry := hass.config_entries.async_get_entry(data["entry_id"])
+ ):
+ return MigrateToV2ApiRepairFlow(entry)
+
+ raise ValueError(f"unknown repair {issue_id}") # pragma: no cover
diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py
index 8b822bffc50..582c65f2838 100644
--- a/homeassistant/components/homewizard/sensor.py
+++ b/homeassistant/components/homewizard/sensor.py
@@ -4,9 +4,10 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime, timedelta
from typing import Final
-from homewizard_energy.v1.models import Data, ExternalDevice
+from homewizard_energy.models import CombinedModels, ExternalDevice
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
@@ -33,6 +34,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
+from homeassistant.util.dt import utcnow
from . import HomeWizardConfigEntry
from .const import DOMAIN
@@ -46,9 +48,9 @@ PARALLEL_UPDATES = 1
class HomeWizardSensorEntityDescription(SensorEntityDescription):
"""Class describing HomeWizard sensor entities."""
- enabled_fn: Callable[[Data], bool] = lambda data: True
- has_fn: Callable[[Data], bool]
- value_fn: Callable[[Data], StateType]
+ enabled_fn: Callable[[CombinedModels], bool] = lambda x: True
+ has_fn: Callable[[CombinedModels], bool]
+ value_fn: Callable[[CombinedModels], StateType | datetime]
@dataclass(frozen=True, kw_only=True)
@@ -64,41 +66,56 @@ def to_percentage(value: float | None) -> float | None:
return value * 100 if value is not None else None
+def time_to_datetime(value: int | None) -> datetime | None:
+ """Convert seconds to datetime when value is not None."""
+ return (
+ utcnow().replace(microsecond=0) - timedelta(seconds=value)
+ if value is not None
+ else None
+ )
+
+
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="smr_version",
translation_key="dsmr_version",
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.smr_version is not None,
- value_fn=lambda data: data.smr_version,
+ has_fn=lambda data: data.measurement.protocol_version is not None,
+ value_fn=lambda data: data.measurement.protocol_version,
),
HomeWizardSensorEntityDescription(
key="meter_model",
translation_key="meter_model",
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.meter_model is not None,
- value_fn=lambda data: data.meter_model,
+ has_fn=lambda data: data.measurement.meter_model is not None,
+ value_fn=lambda data: data.measurement.meter_model,
),
HomeWizardSensorEntityDescription(
key="unique_meter_id",
translation_key="unique_meter_id",
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.unique_meter_id is not None,
- value_fn=lambda data: data.unique_meter_id,
+ has_fn=lambda data: data.measurement.unique_id is not None,
+ value_fn=lambda data: data.measurement.unique_id,
),
HomeWizardSensorEntityDescription(
key="wifi_ssid",
translation_key="wifi_ssid",
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.wifi_ssid is not None,
- value_fn=lambda data: data.wifi_ssid,
+ has_fn=(
+ lambda data: data.system is not None and data.system.wifi_ssid is not None
+ ),
+ value_fn=(
+ lambda data: data.system.wifi_ssid if data.system is not None else None
+ ),
),
HomeWizardSensorEntityDescription(
key="active_tariff",
translation_key="active_tariff",
- has_fn=lambda data: data.active_tariff is not None,
- value_fn=lambda data: (
- None if data.active_tariff is None else str(data.active_tariff)
+ has_fn=lambda data: data.measurement.tariff is not None,
+ value_fn=(
+ lambda data: None
+ if data.measurement.tariff is None
+ else str(data.measurement.tariff)
),
device_class=SensorDeviceClass.ENUM,
options=["1", "2", "3", "4"],
@@ -110,8 +127,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.wifi_strength is not None,
- value_fn=lambda data: data.wifi_strength,
+ has_fn=(
+ lambda data: data.system is not None
+ and data.system.wifi_strength_pct is not None
+ ),
+ value_fn=(
+ lambda data: data.system.wifi_strength_pct
+ if data.system is not None
+ else None
+ ),
),
HomeWizardSensorEntityDescription(
key="total_power_import_kwh",
@@ -119,8 +143,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_import_kwh is not None,
- value_fn=lambda data: data.total_energy_import_kwh,
+ has_fn=lambda data: data.measurement.energy_import_kwh is not None,
+ value_fn=lambda data: data.measurement.energy_import_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t1_kwh",
@@ -131,10 +155,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: (
# SKT/SDM230/630 provides both total and tariff 1: duplicate.
- data.total_energy_import_t1_kwh is not None
- and data.total_energy_export_t2_kwh is not None
+ data.measurement.energy_import_t1_kwh is not None
+ and data.measurement.energy_export_t2_kwh is not None
),
- value_fn=lambda data: data.total_energy_import_t1_kwh,
+ value_fn=lambda data: data.measurement.energy_import_t1_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t2_kwh",
@@ -143,8 +167,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_import_t2_kwh is not None,
- value_fn=lambda data: data.total_energy_import_t2_kwh,
+ has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None,
+ value_fn=lambda data: data.measurement.energy_import_t2_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t3_kwh",
@@ -153,8 +177,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_import_t3_kwh is not None,
- value_fn=lambda data: data.total_energy_import_t3_kwh,
+ has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None,
+ value_fn=lambda data: data.measurement.energy_import_t3_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t4_kwh",
@@ -163,8 +187,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_import_t4_kwh is not None,
- value_fn=lambda data: data.total_energy_import_t4_kwh,
+ has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None,
+ value_fn=lambda data: data.measurement.energy_import_t4_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_export_kwh",
@@ -172,9 +196,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_export_kwh is not None,
- enabled_fn=lambda data: data.total_energy_export_kwh != 0,
- value_fn=lambda data: data.total_energy_export_kwh,
+ has_fn=lambda data: data.measurement.energy_export_kwh is not None,
+ enabled_fn=lambda data: data.measurement.energy_export_kwh != 0,
+ value_fn=lambda data: data.measurement.energy_export_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t1_kwh",
@@ -185,11 +209,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: (
# SKT/SDM230/630 provides both total and tariff 1: duplicate.
- data.total_energy_export_t1_kwh is not None
- and data.total_energy_export_t2_kwh is not None
+ data.measurement.energy_export_t1_kwh is not None
+ and data.measurement.energy_export_t2_kwh is not None
),
- enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0,
- value_fn=lambda data: data.total_energy_export_t1_kwh,
+ enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0,
+ value_fn=lambda data: data.measurement.energy_export_t1_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t2_kwh",
@@ -198,9 +222,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_export_t2_kwh is not None,
- enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0,
- value_fn=lambda data: data.total_energy_export_t2_kwh,
+ has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None,
+ enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0,
+ value_fn=lambda data: data.measurement.energy_export_t2_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t3_kwh",
@@ -209,9 +233,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_export_t3_kwh is not None,
- enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0,
- value_fn=lambda data: data.total_energy_export_t3_kwh,
+ has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None,
+ enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0,
+ value_fn=lambda data: data.measurement.energy_export_t3_kwh,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t4_kwh",
@@ -220,9 +244,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_energy_export_t4_kwh is not None,
- enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0,
- value_fn=lambda data: data.total_energy_export_t4_kwh,
+ has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None,
+ enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0,
+ value_fn=lambda data: data.measurement.energy_export_t4_kwh,
),
HomeWizardSensorEntityDescription(
key="active_power_w",
@@ -230,8 +254,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
- has_fn=lambda data: data.active_power_w is not None,
- value_fn=lambda data: data.active_power_w,
+ has_fn=lambda data: data.measurement.power_w is not None,
+ value_fn=lambda data: data.measurement.power_w,
),
HomeWizardSensorEntityDescription(
key="active_power_l1_w",
@@ -241,8 +265,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
- has_fn=lambda data: data.active_power_l1_w is not None,
- value_fn=lambda data: data.active_power_l1_w,
+ has_fn=lambda data: data.measurement.power_l1_w is not None,
+ value_fn=lambda data: data.measurement.power_l1_w,
),
HomeWizardSensorEntityDescription(
key="active_power_l2_w",
@@ -252,8 +276,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
- has_fn=lambda data: data.active_power_l2_w is not None,
- value_fn=lambda data: data.active_power_l2_w,
+ has_fn=lambda data: data.measurement.power_l2_w is not None,
+ value_fn=lambda data: data.measurement.power_l2_w,
),
HomeWizardSensorEntityDescription(
key="active_power_l3_w",
@@ -263,8 +287,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
- has_fn=lambda data: data.active_power_l3_w is not None,
- value_fn=lambda data: data.active_power_l3_w,
+ has_fn=lambda data: data.measurement.power_l3_w is not None,
+ value_fn=lambda data: data.measurement.power_l3_w,
),
HomeWizardSensorEntityDescription(
key="active_voltage_v",
@@ -272,8 +296,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_voltage_v is not None,
- value_fn=lambda data: data.active_voltage_v,
+ has_fn=lambda data: data.measurement.voltage_v is not None,
+ value_fn=lambda data: data.measurement.voltage_v,
),
HomeWizardSensorEntityDescription(
key="active_voltage_l1_v",
@@ -283,8 +307,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_voltage_l1_v is not None,
- value_fn=lambda data: data.active_voltage_l1_v,
+ has_fn=lambda data: data.measurement.voltage_l1_v is not None,
+ value_fn=lambda data: data.measurement.voltage_l1_v,
),
HomeWizardSensorEntityDescription(
key="active_voltage_l2_v",
@@ -294,8 +318,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_voltage_l2_v is not None,
- value_fn=lambda data: data.active_voltage_l2_v,
+ has_fn=lambda data: data.measurement.voltage_l2_v is not None,
+ value_fn=lambda data: data.measurement.voltage_l2_v,
),
HomeWizardSensorEntityDescription(
key="active_voltage_l3_v",
@@ -305,8 +329,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_voltage_l3_v is not None,
- value_fn=lambda data: data.active_voltage_l3_v,
+ has_fn=lambda data: data.measurement.voltage_l3_v is not None,
+ value_fn=lambda data: data.measurement.voltage_l3_v,
),
HomeWizardSensorEntityDescription(
key="active_current_a",
@@ -314,8 +338,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_current_a is not None,
- value_fn=lambda data: data.active_current_a,
+ has_fn=lambda data: data.measurement.current_a is not None,
+ value_fn=lambda data: data.measurement.current_a,
),
HomeWizardSensorEntityDescription(
key="active_current_l1_a",
@@ -325,8 +349,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_current_l1_a is not None,
- value_fn=lambda data: data.active_current_l1_a,
+ has_fn=lambda data: data.measurement.current_l1_a is not None,
+ value_fn=lambda data: data.measurement.current_l1_a,
),
HomeWizardSensorEntityDescription(
key="active_current_l2_a",
@@ -336,8 +360,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_current_l2_a is not None,
- value_fn=lambda data: data.active_current_l2_a,
+ has_fn=lambda data: data.measurement.current_l2_a is not None,
+ value_fn=lambda data: data.measurement.current_l2_a,
),
HomeWizardSensorEntityDescription(
key="active_current_l3_a",
@@ -347,8 +371,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_current_l3_a is not None,
- value_fn=lambda data: data.active_current_l3_a,
+ has_fn=lambda data: data.measurement.current_l3_a is not None,
+ value_fn=lambda data: data.measurement.current_l3_a,
),
HomeWizardSensorEntityDescription(
key="active_frequency_hz",
@@ -356,8 +380,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_frequency_hz is not None,
- value_fn=lambda data: data.active_frequency_hz,
+ has_fn=lambda data: data.measurement.frequency_hz is not None,
+ value_fn=lambda data: data.measurement.frequency_hz,
),
HomeWizardSensorEntityDescription(
key="active_apparent_power_va",
@@ -365,8 +389,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_apparent_power_va is not None,
- value_fn=lambda data: data.active_apparent_power_va,
+ has_fn=lambda data: data.measurement.apparent_power_va is not None,
+ value_fn=lambda data: data.measurement.apparent_power_va,
),
HomeWizardSensorEntityDescription(
key="active_apparent_power_l1_va",
@@ -376,8 +400,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_apparent_power_l1_va is not None,
- value_fn=lambda data: data.active_apparent_power_l1_va,
+ has_fn=lambda data: data.measurement.apparent_power_l1_va is not None,
+ value_fn=lambda data: data.measurement.apparent_power_l1_va,
),
HomeWizardSensorEntityDescription(
key="active_apparent_power_l2_va",
@@ -387,8 +411,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_apparent_power_l2_va is not None,
- value_fn=lambda data: data.active_apparent_power_l2_va,
+ has_fn=lambda data: data.measurement.apparent_power_l2_va is not None,
+ value_fn=lambda data: data.measurement.apparent_power_l2_va,
),
HomeWizardSensorEntityDescription(
key="active_apparent_power_l3_va",
@@ -398,8 +422,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_apparent_power_l3_va is not None,
- value_fn=lambda data: data.active_apparent_power_l3_va,
+ has_fn=lambda data: data.measurement.apparent_power_l3_va is not None,
+ value_fn=lambda data: data.measurement.apparent_power_l3_va,
),
HomeWizardSensorEntityDescription(
key="active_reactive_power_var",
@@ -407,8 +431,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.REACTIVE_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_reactive_power_var is not None,
- value_fn=lambda data: data.active_reactive_power_var,
+ has_fn=lambda data: data.measurement.reactive_power_var is not None,
+ value_fn=lambda data: data.measurement.reactive_power_var,
),
HomeWizardSensorEntityDescription(
key="active_reactive_power_l1_var",
@@ -418,8 +442,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.REACTIVE_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_reactive_power_l1_var is not None,
- value_fn=lambda data: data.active_reactive_power_l1_var,
+ has_fn=lambda data: data.measurement.reactive_power_l1_var is not None,
+ value_fn=lambda data: data.measurement.reactive_power_l1_var,
),
HomeWizardSensorEntityDescription(
key="active_reactive_power_l2_var",
@@ -429,8 +453,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.REACTIVE_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_reactive_power_l2_var is not None,
- value_fn=lambda data: data.active_reactive_power_l2_var,
+ has_fn=lambda data: data.measurement.reactive_power_l2_var is not None,
+ value_fn=lambda data: data.measurement.reactive_power_l2_var,
),
HomeWizardSensorEntityDescription(
key="active_reactive_power_l3_var",
@@ -440,8 +464,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.REACTIVE_POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_reactive_power_l3_var is not None,
- value_fn=lambda data: data.active_reactive_power_l3_var,
+ has_fn=lambda data: data.measurement.reactive_power_l3_var is not None,
+ value_fn=lambda data: data.measurement.reactive_power_l3_var,
),
HomeWizardSensorEntityDescription(
key="active_power_factor",
@@ -449,8 +473,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_power_factor is not None,
- value_fn=lambda data: to_percentage(data.active_power_factor),
+ has_fn=lambda data: data.measurement.power_factor is not None,
+ value_fn=lambda data: to_percentage(data.measurement.power_factor),
),
HomeWizardSensorEntityDescription(
key="active_power_factor_l1",
@@ -460,8 +484,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_power_factor_l1 is not None,
- value_fn=lambda data: to_percentage(data.active_power_factor_l1),
+ has_fn=lambda data: data.measurement.power_factor_l1 is not None,
+ value_fn=lambda data: to_percentage(data.measurement.power_factor_l1),
),
HomeWizardSensorEntityDescription(
key="active_power_factor_l2",
@@ -471,8 +495,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_power_factor_l2 is not None,
- value_fn=lambda data: to_percentage(data.active_power_factor_l2),
+ has_fn=lambda data: data.measurement.power_factor_l2 is not None,
+ value_fn=lambda data: to_percentage(data.measurement.power_factor_l2),
),
HomeWizardSensorEntityDescription(
key="active_power_factor_l3",
@@ -482,94 +506,94 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- has_fn=lambda data: data.active_power_factor_l3 is not None,
- value_fn=lambda data: to_percentage(data.active_power_factor_l3),
+ has_fn=lambda data: data.measurement.power_factor_l3 is not None,
+ value_fn=lambda data: to_percentage(data.measurement.power_factor_l3),
),
HomeWizardSensorEntityDescription(
key="voltage_sag_l1_count",
translation_key="voltage_sag_phase_count",
translation_placeholders={"phase": "1"},
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.voltage_sag_l1_count is not None,
- value_fn=lambda data: data.voltage_sag_l1_count,
+ has_fn=lambda data: data.measurement.voltage_sag_l1_count is not None,
+ value_fn=lambda data: data.measurement.voltage_sag_l1_count,
),
HomeWizardSensorEntityDescription(
key="voltage_sag_l2_count",
translation_key="voltage_sag_phase_count",
translation_placeholders={"phase": "2"},
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.voltage_sag_l2_count is not None,
- value_fn=lambda data: data.voltage_sag_l2_count,
+ has_fn=lambda data: data.measurement.voltage_sag_l2_count is not None,
+ value_fn=lambda data: data.measurement.voltage_sag_l2_count,
),
HomeWizardSensorEntityDescription(
key="voltage_sag_l3_count",
translation_key="voltage_sag_phase_count",
translation_placeholders={"phase": "3"},
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.voltage_sag_l3_count is not None,
- value_fn=lambda data: data.voltage_sag_l3_count,
+ has_fn=lambda data: data.measurement.voltage_sag_l3_count is not None,
+ value_fn=lambda data: data.measurement.voltage_sag_l3_count,
),
HomeWizardSensorEntityDescription(
key="voltage_swell_l1_count",
translation_key="voltage_swell_phase_count",
translation_placeholders={"phase": "1"},
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.voltage_swell_l1_count is not None,
- value_fn=lambda data: data.voltage_swell_l1_count,
+ has_fn=lambda data: data.measurement.voltage_swell_l1_count is not None,
+ value_fn=lambda data: data.measurement.voltage_swell_l1_count,
),
HomeWizardSensorEntityDescription(
key="voltage_swell_l2_count",
translation_key="voltage_swell_phase_count",
translation_placeholders={"phase": "2"},
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.voltage_swell_l2_count is not None,
- value_fn=lambda data: data.voltage_swell_l2_count,
+ has_fn=lambda data: data.measurement.voltage_swell_l2_count is not None,
+ value_fn=lambda data: data.measurement.voltage_swell_l2_count,
),
HomeWizardSensorEntityDescription(
key="voltage_swell_l3_count",
translation_key="voltage_swell_phase_count",
translation_placeholders={"phase": "3"},
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.voltage_swell_l3_count is not None,
- value_fn=lambda data: data.voltage_swell_l3_count,
+ has_fn=lambda data: data.measurement.voltage_swell_l3_count is not None,
+ value_fn=lambda data: data.measurement.voltage_swell_l3_count,
),
HomeWizardSensorEntityDescription(
key="any_power_fail_count",
translation_key="any_power_fail_count",
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.any_power_fail_count is not None,
- value_fn=lambda data: data.any_power_fail_count,
+ has_fn=lambda data: data.measurement.any_power_fail_count is not None,
+ value_fn=lambda data: data.measurement.any_power_fail_count,
),
HomeWizardSensorEntityDescription(
key="long_power_fail_count",
translation_key="long_power_fail_count",
entity_category=EntityCategory.DIAGNOSTIC,
- has_fn=lambda data: data.long_power_fail_count is not None,
- value_fn=lambda data: data.long_power_fail_count,
+ has_fn=lambda data: data.measurement.long_power_fail_count is not None,
+ value_fn=lambda data: data.measurement.long_power_fail_count,
),
HomeWizardSensorEntityDescription(
key="active_power_average_w",
translation_key="active_power_average_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
- has_fn=lambda data: data.active_power_average_w is not None,
- value_fn=lambda data: data.active_power_average_w,
+ has_fn=lambda data: data.measurement.average_power_15m_w is not None,
+ value_fn=lambda data: data.measurement.average_power_15m_w,
),
HomeWizardSensorEntityDescription(
key="monthly_power_peak_w",
translation_key="monthly_power_peak_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
- has_fn=lambda data: data.monthly_power_peak_w is not None,
- value_fn=lambda data: data.monthly_power_peak_w,
+ has_fn=lambda data: data.measurement.monthly_power_peak_w is not None,
+ value_fn=lambda data: data.measurement.monthly_power_peak_w,
),
HomeWizardSensorEntityDescription(
key="active_liter_lpm",
translation_key="active_liter_lpm",
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
- has_fn=lambda data: data.active_liter_lpm is not None,
- value_fn=lambda data: data.active_liter_lpm,
+ has_fn=lambda data: data.measurement.active_liter_lpm is not None,
+ value_fn=lambda data: data.measurement.active_liter_lpm,
),
HomeWizardSensorEntityDescription(
key="total_liter_m3",
@@ -577,8 +601,39 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
- has_fn=lambda data: data.total_liter_m3 is not None,
- value_fn=lambda data: data.total_liter_m3,
+ has_fn=lambda data: data.measurement.total_liter_m3 is not None,
+ value_fn=lambda data: data.measurement.total_liter_m3,
+ ),
+ HomeWizardSensorEntityDescription(
+ key="state_of_charge_pct",
+ translation_key="state_of_charge_pct",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
+ has_fn=lambda data: data.measurement.state_of_charge_pct is not None,
+ value_fn=lambda data: data.measurement.state_of_charge_pct,
+ ),
+ HomeWizardSensorEntityDescription(
+ key="cycles",
+ translation_key="cycles",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ has_fn=lambda data: data.measurement.cycles is not None,
+ value_fn=lambda data: data.measurement.cycles,
+ ),
+ HomeWizardSensorEntityDescription(
+ key="uptime",
+ translation_key="uptime",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=(
+ lambda data: data.system is not None and data.system.uptime_s is not None
+ ),
+ value_fn=(
+ lambda data: time_to_datetime(data.system.uptime_s) if data.system else None
+ ),
),
)
@@ -624,19 +679,20 @@ async def async_setup_entry(
) -> None:
"""Initialize sensors."""
- data = entry.runtime_data.data.data
-
# Initialize default sensors
entities: list = [
HomeWizardSensorEntity(entry.runtime_data, description)
for description in SENSORS
- if description.has_fn(data)
+ if description.has_fn(entry.runtime_data.data)
]
# Initialize external devices
- if data.external_devices is not None:
- for unique_id, device in data.external_devices.items():
- if description := EXTERNAL_SENSORS.get(device.meter_type):
+ measurement = entry.runtime_data.data.measurement
+ if measurement.external_devices is not None:
+ for unique_id, device in measurement.external_devices.items():
+ if device.type is not None and (
+ description := EXTERNAL_SENSORS.get(device.type)
+ ):
# Add external device
entities.append(
HomeWizardExternalSensorEntity(
@@ -661,13 +717,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity):
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
- if not description.enabled_fn(self.coordinator.data.data):
+ if not description.enabled_fn(self.coordinator.data):
self._attr_entity_registry_enabled_default = False
@property
- def native_value(self) -> StateType:
+ def native_value(self) -> StateType | datetime | None:
"""Return the sensor value."""
- return self.entity_description.value_fn(self.coordinator.data.data)
+ return self.entity_description.value_fn(self.coordinator.data)
@property
def available(self) -> bool:
@@ -712,8 +768,8 @@ class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity):
def device(self) -> ExternalDevice | None:
"""Return ExternalDevice object."""
return (
- self.coordinator.data.data.external_devices[self._device_id]
- if self.coordinator.data.data.external_devices is not None
+ self.coordinator.data.measurement.external_devices[self._device_id]
+ if self.coordinator.data.measurement.external_devices is not None
else None
)
diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json
index 4309664c4c8..806dbf6e083 100644
--- a/homeassistant/components/homewizard/strings.json
+++ b/homeassistant/components/homewizard/strings.json
@@ -15,9 +15,17 @@
"title": "Confirm",
"description": "Do you want to set up {product_type} ({serial}) at {ip_address}?"
},
- "reauth_confirm": {
+ "reauth_enable_api": {
"description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings."
},
+ "reauth_confirm_update_token": {
+ "title": "Re-authenticate",
+ "description": "[%key:component::homewizard::config::step::authorize::description%]"
+ },
+ "authorize": {
+ "title": "Authorize",
+ "description": "Press the button on the HomeWizard Energy device, then select the button below."
+ },
"reconfigure": {
"description": "Update configuration for {title}.",
"data": {
@@ -30,7 +38,8 @@
},
"error": {
"api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.",
- "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network"
+ "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network",
+ "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -38,7 +47,8 @@
"device_not_supported": "This device is not supported",
"unknown_error": "[%key:common::config_flow::error::unknown%]",
"unsupported_api_version": "Detected unsupported API version",
- "reauth_successful": "Enabling API was successful",
+ "reauth_enable_api_successful": "Enabling API was successful",
+ "reauth_successful": "Authorization successful",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "The configured device is not the same found on this IP address."
}
@@ -121,6 +131,15 @@
},
"total_liter_m3": {
"name": "Total water usage"
+ },
+ "cycles": {
+ "name": "Battery cycles"
+ },
+ "state_of_charge_pct": {
+ "name": "State of charge"
+ },
+ "uptime": {
+ "name": "Uptime"
}
},
"switch": {
@@ -139,5 +158,26 @@
"communication_error": {
"message": "An error occurred while communicating with HomeWizard device"
}
+ },
+ "issues": {
+ "migrate_to_v2_api": {
+ "title": "Update authentication method",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::homewizard::issues::migrate_to_v2_api::title%]",
+ "description": "Your {title} now supports a more secure and feature-rich communication method. To take advantage of this, you need to reconfigure the integration.\n\nSelect **Submit** to start the reconfiguration."
+ },
+ "authorize": {
+ "title": "[%key:component::homewizard::config::step::authorize::title%]",
+ "description": "[%key:component::homewizard::config::step::authorize::description%]"
+ }
+ },
+ "error": {
+ "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]",
+ "unknown_error": "[%key:common::config_flow::error::unknown%]"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py
index aa0af17f578..8ebb56433b1 100644
--- a/homeassistant/components/homewizard/switch.py
+++ b/homeassistant/components/homewizard/switch.py
@@ -6,7 +6,8 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
-from homewizard_energy import HomeWizardEnergyV1
+from homewizard_energy import HomeWizardEnergy
+from homewizard_energy.models import CombinedModels as DeviceResponseEntry
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -18,7 +19,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeWizardConfigEntry
-from .const import DeviceResponseEntry
from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
@@ -31,9 +31,9 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription):
"""Class describing HomeWizard switch entities."""
available_fn: Callable[[DeviceResponseEntry], bool]
- create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool]
+ create_fn: Callable[[DeviceResponseEntry], bool]
is_on_fn: Callable[[DeviceResponseEntry], bool | None]
- set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]]
+ set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]]
SWITCHES = [
@@ -41,28 +41,28 @@ SWITCHES = [
key="power_on",
name=None,
device_class=SwitchDeviceClass.OUTLET,
- create_fn=lambda coordinator: coordinator.supports_state(),
- available_fn=lambda data: data.state is not None and not data.state.switch_lock,
- is_on_fn=lambda data: data.state.power_on if data.state else None,
- set_fn=lambda api, active: api.state_set(power_on=active),
+ create_fn=lambda x: x.device.supports_state(),
+ available_fn=lambda x: x.state is not None and not x.state.switch_lock,
+ is_on_fn=lambda x: x.state.power_on if x.state else None,
+ set_fn=lambda api, active: api.state(power_on=active),
),
HomeWizardSwitchEntityDescription(
key="switch_lock",
translation_key="switch_lock",
entity_category=EntityCategory.CONFIG,
- create_fn=lambda coordinator: coordinator.supports_state(),
- available_fn=lambda data: data.state is not None,
- is_on_fn=lambda data: data.state.switch_lock if data.state else None,
- set_fn=lambda api, active: api.state_set(switch_lock=active),
+ create_fn=lambda x: x.device.supports_state(),
+ available_fn=lambda x: x.state is not None,
+ is_on_fn=lambda x: x.state.switch_lock if x.state else None,
+ set_fn=lambda api, active: api.state(switch_lock=active),
),
HomeWizardSwitchEntityDescription(
key="cloud_connection",
translation_key="cloud_connection",
entity_category=EntityCategory.CONFIG,
- create_fn=lambda _: True,
- available_fn=lambda data: data.system is not None,
- is_on_fn=lambda data: data.system.cloud_enabled if data.system else None,
- set_fn=lambda api, active: api.system_set(cloud_enabled=active),
+ create_fn=lambda x: x.device.supports_cloud_enable(),
+ available_fn=lambda x: x.system is not None,
+ is_on_fn=lambda x: x.system.cloud_enabled if x.system else None,
+ set_fn=lambda api, active: api.system(cloud_enabled=active),
),
]
@@ -76,7 +76,7 @@ async def async_setup_entry(
async_add_entities(
HomeWizardSwitchEntity(entry.runtime_data, description)
for description in SWITCHES
- if description.create_fn(entry.runtime_data)
+ if description.create_fn(entry.runtime_data.data)
)
diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py
index e9e8c969b61..75fdeb4f8cc 100644
--- a/homeassistant/components/homeworks/__init__.py
+++ b/homeassistant/components/homeworks/__init__.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json
index 977e6be8afd..10cc2e61fb9 100644
--- a/homeassistant/components/homeworks/strings.json
+++ b/homeassistant/components/homeworks/strings.json
@@ -167,15 +167,15 @@
"services": {
"send_command": {
"name": "Send command",
- "description": "Send custom command to a controller",
+ "description": "Sends a custom command to a controller",
"fields": {
"command": {
"name": "Command",
- "description": "Command to send to the controller. This can either be a single command or a list of commands."
+ "description": "The command to send to the controller. This can either be a single command or a list of commands."
},
"controller_id": {
"name": "Controller ID",
- "description": "The controller to which to send command."
+ "description": "The controller to which to send the command."
}
}
}
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index 7398ada23be..1df5eb9601b 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -294,7 +294,7 @@ class HoneywellUSThermostat(ClimateEntity):
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
if self.hvac_mode == HVACMode.OFF:
- return None
+ return HVACAction.OFF
return HW_MODE_TO_HA_HVAC_ACTION.get(self._device.equipment_output_status)
@property
diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json
index 4a50e326965..7fa102c6599 100644
--- a/homeassistant/components/honeywell/manifest.json
+++ b/homeassistant/components/honeywell/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/honeywell",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
- "requirements": ["AIOSomecomfort==0.0.28"]
+ "requirements": ["AIOSomecomfort==0.0.32"]
}
diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py
index ba3ca5e2e35..d1b733ab84a 100644
--- a/homeassistant/components/horizon/media_player.py
+++ b/homeassistant/components/horizon/media_player.py
@@ -21,7 +21,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py
index 0eeb443cf2d..b4263f53d24 100644
--- a/homeassistant/components/hp_ilo/sensor.py
+++ b/homeassistant/components/hp_ilo/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index 48cc0598479..e9ebdb9da67 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -154,6 +154,7 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = (
"tag",
"timestamp",
"vibrate",
+ "silent",
)
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index 95cdee9ab9e..8ee27039441 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -37,8 +37,12 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import frame, issue_registry as ir, storage
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ frame,
+ issue_registry as ir,
+ storage,
+)
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
KEY_AUTHENTICATED, # noqa: F401
diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py
index c8fc8ffb11b..821d44eebaa 100644
--- a/homeassistant/components/http/ban.py
+++ b/homeassistant/components/http/ban.py
@@ -26,9 +26,9 @@ import voluptuous as vol
from homeassistant.config import load_yaml_config_file
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio
-from homeassistant.util import dt as dt_util, yaml
+from homeassistant.util import dt as dt_util, yaml as yaml_util
from .const import KEY_HASS
from .view import HomeAssistantView
@@ -244,7 +244,7 @@ class IpBanManager:
str(ip_ban.ip_address): {ATTR_BANNED_AT: ip_ban.banned_at.isoformat()}
}
# Write in a single write call to avoid interleaved writes
- out.write("\n" + yaml.dump(ip_))
+ out.write("\n" + yaml_util.dump(ip_))
async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None:
"""Add a new IP address to the banned list."""
diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py
index 9ca34af3741..99877eaf0be 100644
--- a/homeassistant/components/http/static.py
+++ b/homeassistant/components/http/static.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
-import sys
from typing import Final
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
@@ -18,14 +17,7 @@ CACHE_HEADER = f"public, max-age={CACHE_TIME}"
CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER}
RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512)
-if sys.version_info >= (3, 13):
- # guess_type is soft-deprecated in 3.13
- # for paths and should only be used for
- # URLs. guess_file_type should be used
- # for paths instead.
- _GUESSER = CONTENT_TYPES.guess_file_type
-else:
- _GUESSER = CONTENT_TYPES.guess_type
+_GUESSER = CONTENT_TYPES.guess_file_type
class CachingStaticResource(StaticResource):
diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py
index 4ca39eaab0c..f633433c9e4 100644
--- a/homeassistant/components/http/web_runner.py
+++ b/homeassistant/components/http/web_runner.py
@@ -22,7 +22,7 @@ class HomeAssistantTCPSite(web.BaseSite):
is merged.
"""
- __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port", "_hosturl")
+ __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port")
def __init__(
self,
diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py
index 08fdae50c51..96e160ece7b 100644
--- a/homeassistant/components/huawei_lte/config_flow.py
+++ b/homeassistant/components/huawei_lte/config_flow.py
@@ -21,7 +21,6 @@ from requests.exceptions import SSLError, Timeout
from url_normalize import url_normalize
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -38,6 +37,14 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_PRESENTATION_URL,
+ ATTR_UPNP_SERIAL,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .const import (
CONF_MANUFACTURER,
@@ -262,7 +269,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=title, data=user_input)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle SSDP initiated config flow."""
@@ -270,13 +277,13 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
assert discovery_info.ssdp_location
url = url_normalize(
discovery_info.upnp.get(
- ssdp.ATTR_UPNP_PRESENTATION_URL,
+ ATTR_UPNP_PRESENTATION_URL,
f"http://{urlparse(discovery_info.ssdp_location).hostname}/",
)
)
unique_id = discovery_info.upnp.get(
- ssdp.ATTR_UPNP_SERIAL, discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
+ ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN]
)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(updates={CONF_URL: url})
@@ -301,12 +308,12 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
self.context.update(
{
"title_placeholders": {
- CONF_NAME: discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
+ CONF_NAME: discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
or "Huawei LTE"
}
}
)
- self.manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER)
+ self.manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER)
self.url = url
return await self._async_show_user_form()
diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json
index e044413f296..879c7215562 100644
--- a/homeassistant/components/huawei_lte/strings.json
+++ b/homeassistant/components/huawei_lte/strings.json
@@ -361,7 +361,7 @@
},
"suspend_integration": {
"name": "Suspend integration",
- "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the resume_integration action to resume.\n.",
+ "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the 'Resume integration' action to resume.",
"fields": {
"url": {
"name": "[%key:common::config_flow::data::url%]",
diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py
index 8d17f810461..db025922ef8 100644
--- a/homeassistant/components/hue/config_flow.py
+++ b/homeassistant/components/hue/config_flow.py
@@ -13,7 +13,6 @@ from aiohue.util import normalize_bridge_id
import slugify as unicode_slug
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -27,6 +26,7 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_ALLOW_HUE_GROUPS,
@@ -214,7 +214,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Hue bridge.
@@ -243,7 +243,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_link()
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Hue bridge on HomeKit.
diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py
index 30555339f19..de6da161fba 100644
--- a/homeassistant/components/hue/services.py
+++ b/homeassistant/components/hue/services.py
@@ -9,7 +9,7 @@ from aiohue import HueBridgeV1, HueBridgeV2
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import verify_domain_control
from .bridge import HueBridge
diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py
index c7f966ce9f2..17cd20b55aa 100644
--- a/homeassistant/components/hue/v2/group.py
+++ b/homeassistant/components/hue/v2/group.py
@@ -24,9 +24,9 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.entity_registry as er
from homeassistant.util import color as color_util
from ..bridge import HueBridge
diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py
index 8c892dca327..de9384edda6 100644
--- a/homeassistant/components/humidifier/__init__.py
+++ b/homeassistant/components/humidifier/__init__.py
@@ -7,7 +7,7 @@ from enum import StrEnum
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py
index 06440480277..9ff36412418 100644
--- a/homeassistant/components/humidifier/device_action.py
+++ b/homeassistant/components/humidifier/device_action.py
@@ -19,8 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_capability, get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolDictType
diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py
index 425fdbcc679..490143c728d 100644
--- a/homeassistant/components/humidifier/intent.py
+++ b/homeassistant/components/humidifier/intent.py
@@ -6,8 +6,7 @@ import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, STATE_OFF
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import intent
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, intent
from . import (
ATTR_AVAILABLE_MODES,
diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py
index d9358db2753..b4bbc37b1e8 100644
--- a/homeassistant/components/hunterdouglas_powerview/__init__.py
+++ b/homeassistant/components/hunterdouglas_powerview/__init__.py
@@ -11,7 +11,7 @@ from aiopvapi.shades import Shades
from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, HUB_EXCEPTIONS
from .coordinator import PowerviewShadeUpdateCoordinator
diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py
index debb9710dbd..c53c08c8ac7 100644
--- a/homeassistant/components/hunterdouglas_powerview/config_flow.py
+++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py
@@ -7,11 +7,12 @@ from typing import TYPE_CHECKING, Any, Self
import voluptuous as vol
-from homeassistant.components import dhcp, zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, HUB_EXCEPTIONS
from .util import async_connect_hub
@@ -110,7 +111,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN):
return info, None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
self.discovered_ip = discovery_info.ip
@@ -118,7 +119,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.discovered_ip = discovery_info.host
@@ -128,7 +129,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle HomeKit discovery."""
self.discovered_ip = discovery_info.host
diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py
index ba572ecefce..f2a841a7d0e 100644
--- a/homeassistant/components/hunterdouglas_powerview/entity.py
+++ b/homeassistant/components/hunterdouglas_powerview/entity.py
@@ -4,7 +4,7 @@ import logging
from aiopvapi.resources.shade import BaseShade, ShadePosition
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py
index da7965250cd..a08256fb0b5 100644
--- a/homeassistant/components/husqvarna_automower/__init__.py
+++ b/homeassistant/components/husqvarna_automower/__init__.py
@@ -9,16 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import (
- aiohttp_client,
- config_entry_oauth2_flow,
- device_registry as dr,
- entity_registry as er,
-)
+from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.util import dt as dt_util
from . import api
-from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -69,8 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) ->
coordinator = AutomowerDataUpdateCoordinator(hass, automower_api)
await coordinator.async_config_entry_first_refresh()
- available_devices = list(coordinator.data)
- cleanup_removed_devices(hass, coordinator.config_entry, available_devices)
entry.runtime_data = coordinator
entry.async_create_background_task(
@@ -86,36 +78,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool:
"""Handle unload of an entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-def cleanup_removed_devices(
- hass: HomeAssistant,
- config_entry: AutomowerConfigEntry,
- available_devices: list[str],
-) -> None:
- """Cleanup entity and device registry from removed devices."""
- device_reg = dr.async_get(hass)
- identifiers = {(DOMAIN, mower_id) for mower_id in available_devices}
- for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id):
- if not set(device.identifiers) & identifiers:
- _LOGGER.debug("Removing obsolete device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=config_entry.entry_id
- )
-
-
-def remove_work_area_entities(
- hass: HomeAssistant,
- config_entry: AutomowerConfigEntry,
- removed_work_areas: set[int],
- mower_id: str,
-) -> None:
- """Remove all unused work area entities for the specified mower."""
- entity_reg = er.async_get(hass)
- for entity_entry in er.async_entries_for_config_entry(
- entity_reg, config_entry.entry_id
- ):
- for work_area_id in removed_work_areas:
- if entity_entry.unique_id.startswith(f"{mower_id}_{work_area_id}_"):
- _LOGGER.info("Deleting: %s", entity_entry.entity_id)
- entity_reg.async_remove(entity_entry.entity_id)
diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py
index 3c23da76797..907d34e812a 100644
--- a/homeassistant/components/husqvarna_automower/binary_sensor.py
+++ b/homeassistant/components/husqvarna_automower/binary_sensor.py
@@ -75,11 +75,16 @@ async def async_setup_entry(
) -> None:
"""Set up binary sensor platform."""
coordinator = entry.runtime_data
- async_add_entities(
- AutomowerBinarySensorEntity(mower_id, coordinator, description)
- for mower_id in coordinator.data
- for description in MOWER_BINARY_SENSOR_TYPES
- )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerBinarySensorEntity(mower_id, coordinator, description)
+ for mower_id in mower_ids
+ for description in MOWER_BINARY_SENSOR_TYPES
+ )
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
+ _async_add_new_devices(set(coordinator.data))
class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity):
diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py
index ce303325496..7e6e581cdf1 100644
--- a/homeassistant/components/husqvarna_automower/button.py
+++ b/homeassistant/components/husqvarna_automower/button.py
@@ -58,12 +58,17 @@ async def async_setup_entry(
) -> None:
"""Set up button platform."""
coordinator = entry.runtime_data
- async_add_entities(
- AutomowerButtonEntity(mower_id, coordinator, description)
- for mower_id in coordinator.data
- for description in MOWER_BUTTON_TYPES
- if description.exists_fn(coordinator.data[mower_id])
- )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerButtonEntity(mower_id, coordinator, description)
+ for mower_id in mower_ids
+ for description in MOWER_BUTTON_TYPES
+ if description.exists_fn(coordinator.data[mower_id])
+ )
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
+ _async_add_new_devices(set(coordinator.data))
class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py
index f3e82fde5d4..9e2ea037afb 100644
--- a/homeassistant/components/husqvarna_automower/calendar.py
+++ b/homeassistant/components/husqvarna_automower/calendar.py
@@ -26,9 +26,14 @@ async def async_setup_entry(
) -> None:
"""Set up lawn mower platform."""
coordinator = entry.runtime_data
- async_add_entities(
- AutomowerCalendarEntity(mower_id, coordinator) for mower_id in coordinator.data
- )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerCalendarEntity(mower_id, coordinator) for mower_id in mower_ids
+ )
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
+ _async_add_new_devices(set(coordinator.data))
class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py
index 57be02e7066..a587b4f3821 100644
--- a/homeassistant/components/husqvarna_automower/coordinator.py
+++ b/homeassistant/components/husqvarna_automower/coordinator.py
@@ -3,21 +3,23 @@
from __future__ import annotations
import asyncio
+from collections.abc import Callable
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from aioautomower.exceptions import (
- ApiException,
- AuthException,
+ ApiError,
+ AuthError,
+ HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError,
- TimeoutException,
)
from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -47,6 +49,12 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
self.api = api
self.ws_connected: bool = False
self.reconnect_time = DEFAULT_RECONNECT_TIME
+ self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
+ self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
+ self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
+ self._devices_last_update: set[str] = set()
+ self._zones_last_update: dict[str, set[str]] = {}
+ self._areas_last_update: dict[str, set[int]] = {}
async def _async_update_data(self) -> dict[str, MowerAttributes]:
"""Subscribe for websocket and poll data from the API."""
@@ -55,12 +63,21 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
self.api.register_data_callback(self.callback)
self.ws_connected = True
try:
- return await self.api.get_status()
- except ApiException as err:
+ data = await self.api.get_status()
+ except ApiError as err:
raise UpdateFailed(err) from err
- except AuthException as err:
+ except AuthError as err:
raise ConfigEntryAuthFailed(err) from err
+ self._async_add_remove_devices(data)
+ for mower_id in data:
+ if data[mower_id].capabilities.stay_out_zones:
+ self._async_add_remove_stay_out_zones(data)
+ for mower_id in data:
+ if data[mower_id].capabilities.work_areas:
+ self._async_add_remove_work_areas(data)
+ return data
+
@callback
def callback(self, ws_data: dict[str, MowerAttributes]) -> None:
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
@@ -83,7 +100,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
"Failed to connect to websocket. Trying to reconnect: %s",
err,
)
- except TimeoutException as err:
+ except HusqvarnaTimeoutError as err:
_LOGGER.debug(
"Failed to listen to websocket. Trying to reconnect: %s",
err,
@@ -96,3 +113,136 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
self.client_listen(hass, entry, automower_client),
"reconnect_task",
)
+
+ def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None:
+ """Add new device, remove non-existing device."""
+ current_devices = set(data)
+
+ # Skip update if no changes
+ if current_devices == self._devices_last_update:
+ return
+
+ # Process removed devices
+ removed_devices = self._devices_last_update - current_devices
+ if removed_devices:
+ _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices)))
+ self._remove_device(removed_devices)
+
+ # Process new device
+ new_devices = current_devices - self._devices_last_update
+ if new_devices:
+ _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
+ self._add_new_devices(new_devices)
+
+ # Update device state
+ self._devices_last_update = current_devices
+
+ def _remove_device(self, removed_devices: set[str]) -> None:
+ """Remove device from the registry."""
+ device_registry = dr.async_get(self.hass)
+ for mower_id in removed_devices:
+ if device := device_registry.async_get_device(
+ identifiers={(DOMAIN, str(mower_id))}
+ ):
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+
+ def _add_new_devices(self, new_devices: set[str]) -> None:
+ """Add new device and trigger callbacks."""
+ for mower_callback in self.new_devices_callbacks:
+ mower_callback(new_devices)
+
+ def _async_add_remove_stay_out_zones(
+ self, data: dict[str, MowerAttributes]
+ ) -> None:
+ """Add new stay-out zones, remove non-existing stay-out zones."""
+ current_zones = {
+ mower_id: set(mower_data.stay_out_zones.zones)
+ for mower_id, mower_data in data.items()
+ if mower_data.capabilities.stay_out_zones
+ and mower_data.stay_out_zones is not None
+ }
+
+ if not self._zones_last_update:
+ self._zones_last_update = current_zones
+ return
+
+ if current_zones == self._zones_last_update:
+ return
+
+ self._zones_last_update = self._update_stay_out_zones(current_zones)
+
+ def _update_stay_out_zones(
+ self, current_zones: dict[str, set[str]]
+ ) -> dict[str, set[str]]:
+ """Update stay-out zones by adding and removing as needed."""
+ new_zones = {
+ mower_id: zones - self._zones_last_update.get(mower_id, set())
+ for mower_id, zones in current_zones.items()
+ }
+ removed_zones = {
+ mower_id: self._zones_last_update.get(mower_id, set()) - zones
+ for mower_id, zones in current_zones.items()
+ }
+
+ for mower_id, zones in new_zones.items():
+ for zone_callback in self.new_zones_callbacks:
+ zone_callback(mower_id, set(zones))
+
+ entity_registry = er.async_get(self.hass)
+ for mower_id, zones in removed_zones.items():
+ for entity_entry in er.async_entries_for_config_entry(
+ entity_registry, self.config_entry.entry_id
+ ):
+ for zone in zones:
+ if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"):
+ entity_registry.async_remove(entity_entry.entity_id)
+
+ return current_zones
+
+ def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None:
+ """Add new work areas, remove non-existing work areas."""
+ current_areas = {
+ mower_id: set(mower_data.work_areas)
+ for mower_id, mower_data in data.items()
+ if mower_data.capabilities.work_areas and mower_data.work_areas is not None
+ }
+
+ if not self._areas_last_update:
+ self._areas_last_update = current_areas
+ return
+
+ if current_areas == self._areas_last_update:
+ return
+
+ self._areas_last_update = self._update_work_areas(current_areas)
+
+ def _update_work_areas(
+ self, current_areas: dict[str, set[int]]
+ ) -> dict[str, set[int]]:
+ """Update work areas by adding and removing as needed."""
+ new_areas = {
+ mower_id: areas - self._areas_last_update.get(mower_id, set())
+ for mower_id, areas in current_areas.items()
+ }
+ removed_areas = {
+ mower_id: self._areas_last_update.get(mower_id, set()) - areas
+ for mower_id, areas in current_areas.items()
+ }
+
+ for mower_id, areas in new_areas.items():
+ for area_callback in self.new_areas_callbacks:
+ area_callback(mower_id, set(areas))
+
+ entity_registry = er.async_get(self.hass)
+ for mower_id, areas in removed_areas.items():
+ for entity_entry in er.async_entries_for_config_entry(
+ entity_registry, self.config_entry.entry_id
+ ):
+ for area in areas:
+ if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"):
+ entity_registry.async_remove(entity_entry.entity_id)
+
+ return current_areas
diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py
index 520eaceb1d0..2fd59b63014 100644
--- a/homeassistant/components/husqvarna_automower/device_tracker.py
+++ b/homeassistant/components/husqvarna_automower/device_tracker.py
@@ -19,11 +19,16 @@ async def async_setup_entry(
) -> None:
"""Set up device tracker platform."""
coordinator = entry.runtime_data
- async_add_entities(
- AutomowerDeviceTrackerEntity(mower_id, coordinator)
- for mower_id in coordinator.data
- if coordinator.data[mower_id].capabilities.position
- )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerDeviceTrackerEntity(mower_id, coordinator)
+ for mower_id in mower_ids
+ if coordinator.data[mower_id].capabilities.position
+ )
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
+ _async_add_new_devices(set(coordinator.data))
class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity):
diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py
index 5b5156e5f1d..150a3d18d87 100644
--- a/homeassistant/components/husqvarna_automower/entity.py
+++ b/homeassistant/components/husqvarna_automower/entity.py
@@ -8,7 +8,7 @@ import functools
import logging
from typing import TYPE_CHECKING, Any, Concatenate
-from aioautomower.exceptions import ApiException
+from aioautomower.exceptions import ApiError
from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea
from homeassistant.core import callback
@@ -67,7 +67,7 @@ def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P](
async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
- except ApiException as exception:
+ except ApiError as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_send_failed",
diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py
index 9b3ce7dab1a..dd75a8b9bc4 100644
--- a/homeassistant/components/husqvarna_automower/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower/lawn_mower.py
@@ -53,10 +53,15 @@ async def async_setup_entry(
) -> None:
"""Set up lawn mower platform."""
coordinator = entry.runtime_data
- async_add_entities(
- AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data
- )
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ [AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in mower_ids]
+ )
+
+ _async_add_new_devices(set(coordinator.data))
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
"override_schedule",
diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json
index 1eed2be4575..0eabf5ec0d6 100644
--- a/homeassistant/components/husqvarna_automower/manifest.json
+++ b/homeassistant/components/husqvarna_automower/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
- "requirements": ["aioautomower==2025.1.0"]
+ "requirements": ["aioautomower==2025.1.1"]
}
diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py
index e69b52fab93..d3666494646 100644
--- a/homeassistant/components/husqvarna_automower/number.py
+++ b/homeassistant/components/husqvarna_automower/number.py
@@ -13,7 +13,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import AutomowerConfigEntry, remove_work_area_entities
+from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import (
AutomowerControlEntity,
@@ -111,44 +111,47 @@ async def async_setup_entry(
) -> None:
"""Set up number platform."""
coordinator = entry.runtime_data
- current_work_areas: dict[str, set[int]] = {}
-
- async_add_entities(
- AutomowerNumberEntity(mower_id, coordinator, description)
- for mower_id in coordinator.data
- for description in MOWER_NUMBER_TYPES
- if description.exists_fn(coordinator.data[mower_id])
- )
-
- def _async_work_area_listener() -> None:
- """Listen for new work areas and add/remove entities as needed."""
- for mower_id in coordinator.data:
- if (
- coordinator.data[mower_id].capabilities.work_areas
- and (_work_areas := coordinator.data[mower_id].work_areas) is not None
- ):
- received_work_areas = set(_work_areas.keys())
- current_work_area_set = current_work_areas.setdefault(mower_id, set())
-
- new_work_areas = received_work_areas - current_work_area_set
- removed_work_areas = current_work_area_set - received_work_areas
-
- if new_work_areas:
- current_work_area_set.update(new_work_areas)
- async_add_entities(
- WorkAreaNumberEntity(
- mower_id, coordinator, description, work_area_id
- )
- for description in WORK_AREA_NUMBER_TYPES
- for work_area_id in new_work_areas
+ entities: list[NumberEntity] = []
+ for mower_id in coordinator.data:
+ if coordinator.data[mower_id].capabilities.work_areas:
+ _work_areas = coordinator.data[mower_id].work_areas
+ if _work_areas is not None:
+ entities.extend(
+ WorkAreaNumberEntity(
+ mower_id, coordinator, description, work_area_id
)
+ for description in WORK_AREA_NUMBER_TYPES
+ for work_area_id in _work_areas
+ )
+ entities.extend(
+ AutomowerNumberEntity(mower_id, coordinator, description)
+ for description in MOWER_NUMBER_TYPES
+ if description.exists_fn(coordinator.data[mower_id])
+ )
+ async_add_entities(entities)
- if removed_work_areas:
- remove_work_area_entities(hass, entry, removed_work_areas, mower_id)
- current_work_area_set.difference_update(removed_work_areas)
+ def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None:
+ async_add_entities(
+ WorkAreaNumberEntity(mower_id, coordinator, description, work_area_id)
+ for description in WORK_AREA_NUMBER_TYPES
+ for work_area_id in work_area_ids
+ )
- coordinator.async_add_listener(_async_work_area_listener)
- _async_work_area_listener()
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerNumberEntity(mower_id, coordinator, description)
+ for description in MOWER_NUMBER_TYPES
+ for mower_id in mower_ids
+ if description.exists_fn(coordinator.data[mower_id])
+ )
+ for mower_id in mower_ids:
+ mower_data = coordinator.data[mower_id]
+ if mower_data.capabilities.work_areas and mower_data.work_areas is not None:
+ work_area_ids = set(mower_data.work_areas.keys())
+ _async_add_new_work_areas(mower_id, work_area_ids)
+
+ coordinator.new_areas_callbacks.append(_async_add_new_work_areas)
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
class AutomowerNumberEntity(AutomowerControlEntity, NumberEntity):
diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml
index 2287ccb4d4f..2fa41c02a4c 100644
--- a/homeassistant/components/husqvarna_automower/quality_scale.yaml
+++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml
@@ -57,9 +57,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
- dynamic-devices:
- status: todo
- comment: Add devices dynamically
+ dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -70,9 +68,7 @@ rules:
status: exempt
comment: no configuration possible
repair-issues: done
- stale-devices:
- status: todo
- comment: We only remove devices on reload
+ stale-devices: done
# Platinum
async-dependency: done
diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py
index 65960e897e4..03b1ac02587 100644
--- a/homeassistant/components/husqvarna_automower/select.py
+++ b/homeassistant/components/husqvarna_automower/select.py
@@ -33,11 +33,17 @@ async def async_setup_entry(
) -> None:
"""Set up select platform."""
coordinator = entry.runtime_data
- async_add_entities(
- AutomowerSelectEntity(mower_id, coordinator)
- for mower_id in coordinator.data
- if coordinator.data[mower_id].capabilities.headlights
- )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerSelectEntity(mower_id, coordinator)
+ for mower_id in mower_ids
+ if coordinator.data[mower_id].capabilities.headlights
+ )
+
+ _async_add_new_devices(set(coordinator.data))
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity):
diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py
index fb8603623e4..a2f4b5f4bab 100644
--- a/homeassistant/components/husqvarna_automower/sensor.py
+++ b/homeassistant/components/husqvarna_automower/sensor.py
@@ -434,44 +434,56 @@ async def async_setup_entry(
) -> None:
"""Set up sensor platform."""
coordinator = entry.runtime_data
- current_work_areas: dict[str, set[int]] = {}
-
- async_add_entities(
- AutomowerSensorEntity(mower_id, coordinator, description)
- for mower_id, data in coordinator.data.items()
- for description in MOWER_SENSOR_TYPES
- if description.exists_fn(data)
- )
-
- def _async_work_area_listener() -> None:
- """Listen for new work areas and add sensor entities if they did not exist.
-
- Listening for deletable work areas is managed in the number platform.
- """
- for mower_id in coordinator.data:
- if (
- coordinator.data[mower_id].capabilities.work_areas
- and (_work_areas := coordinator.data[mower_id].work_areas) is not None
- ):
- received_work_areas = set(_work_areas.keys())
- new_work_areas = received_work_areas - current_work_areas.get(
- mower_id, set()
+ entities: list[SensorEntity] = []
+ for mower_id in coordinator.data:
+ if coordinator.data[mower_id].capabilities.work_areas:
+ _work_areas = coordinator.data[mower_id].work_areas
+ if _work_areas is not None:
+ entities.extend(
+ WorkAreaSensorEntity(
+ mower_id, coordinator, description, work_area_id
+ )
+ for description in WORK_AREA_SENSOR_TYPES
+ for work_area_id in _work_areas
+ if description.exists_fn(_work_areas[work_area_id])
)
- if new_work_areas:
- current_work_areas.setdefault(mower_id, set()).update(
- new_work_areas
- )
- async_add_entities(
- WorkAreaSensorEntity(
- mower_id, coordinator, description, work_area_id
- )
- for description in WORK_AREA_SENSOR_TYPES
- for work_area_id in new_work_areas
- if description.exists_fn(_work_areas[work_area_id])
- )
+ entities.extend(
+ AutomowerSensorEntity(mower_id, coordinator, description)
+ for description in MOWER_SENSOR_TYPES
+ if description.exists_fn(coordinator.data[mower_id])
+ )
+ async_add_entities(entities)
- coordinator.async_add_listener(_async_work_area_listener)
- _async_work_area_listener()
+ def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None:
+ mower_data = coordinator.data[mower_id]
+ if mower_data.work_areas is None:
+ return
+
+ async_add_entities(
+ WorkAreaSensorEntity(mower_id, coordinator, description, work_area_id)
+ for description in WORK_AREA_SENSOR_TYPES
+ for work_area_id in work_area_ids
+ if work_area_id in mower_data.work_areas
+ and description.exists_fn(mower_data.work_areas[work_area_id])
+ )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerSensorEntity(mower_id, coordinator, description)
+ for mower_id in mower_ids
+ for description in MOWER_SENSOR_TYPES
+ if description.exists_fn(coordinator.data[mower_id])
+ )
+ for mower_id in mower_ids:
+ mower_data = coordinator.data[mower_id]
+ if mower_data.capabilities.work_areas and mower_data.work_areas is not None:
+ _async_add_new_work_areas(
+ mower_id,
+ set(mower_data.work_areas.keys()),
+ )
+
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
+ coordinator.new_areas_callbacks.append(_async_add_new_work_areas)
class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity):
diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json
index d4c91e29f7d..9bd0bb06b3e 100644
--- a/homeassistant/components/husqvarna_automower/strings.json
+++ b/homeassistant/components/husqvarna_automower/strings.json
@@ -322,7 +322,7 @@
"services": {
"override_schedule": {
"name": "Override schedule",
- "description": "Override the schedule to either mow or park for a duration of time.",
+ "description": "Lets the mower either mow or park for a given duration, overriding all schedules.",
"fields": {
"duration": {
"name": "Duration",
@@ -336,7 +336,7 @@
},
"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.",
+ "description": "Lets the mower mow for a given duration in a specified work area, overriding all schedules.",
"fields": {
"duration": {
"name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]",
diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py
index 352b4c59ba1..d55d51b42fe 100644
--- a/homeassistant/components/husqvarna_automower/switch.py
+++ b/homeassistant/components/husqvarna_automower/switch.py
@@ -7,7 +7,6 @@ from aioautomower.model import MowerModes, StayOutZones, Zone
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AutomowerConfigEntry
@@ -31,82 +30,63 @@ async def async_setup_entry(
) -> None:
"""Set up switch platform."""
coordinator = entry.runtime_data
- current_work_areas: dict[str, set[int]] = {}
- current_stay_out_zones: dict[str, set[str]] = {}
-
- async_add_entities(
+ entities: list[SwitchEntity] = []
+ entities.extend(
AutomowerScheduleSwitchEntity(mower_id, coordinator)
for mower_id in coordinator.data
)
-
- def _async_work_area_listener() -> None:
- """Listen for new work areas and add switch entities if they did not exist.
-
- Listening for deletable work areas is managed in the number platform.
- """
- for mower_id in coordinator.data:
- if (
- coordinator.data[mower_id].capabilities.work_areas
- and (_work_areas := coordinator.data[mower_id].work_areas) is not None
- ):
- received_work_areas = set(_work_areas.keys())
- new_work_areas = received_work_areas - current_work_areas.get(
- mower_id, set()
+ for mower_id in coordinator.data:
+ if coordinator.data[mower_id].capabilities.stay_out_zones:
+ _stay_out_zones = coordinator.data[mower_id].stay_out_zones
+ if _stay_out_zones is not None:
+ entities.extend(
+ StayOutZoneSwitchEntity(coordinator, mower_id, stay_out_zone_uid)
+ for stay_out_zone_uid in _stay_out_zones.zones
)
- if new_work_areas:
- current_work_areas.setdefault(mower_id, set()).update(
- new_work_areas
- )
- async_add_entities(
- WorkAreaSwitchEntity(coordinator, mower_id, work_area_id)
- for work_area_id in new_work_areas
- )
+ if coordinator.data[mower_id].capabilities.work_areas:
+ _work_areas = coordinator.data[mower_id].work_areas
+ if _work_areas is not None:
+ entities.extend(
+ WorkAreaSwitchEntity(coordinator, mower_id, work_area_id)
+ for work_area_id in _work_areas
+ )
+ async_add_entities(entities)
- def _remove_stay_out_zone_entities(
- removed_stay_out_zones: set, mower_id: str
+ def _async_add_new_stay_out_zones(
+ mower_id: str, stay_out_zone_uids: set[str]
) -> None:
- """Remove all unused stay-out zones for all platforms."""
- entity_reg = er.async_get(hass)
- for entity_entry in er.async_entries_for_config_entry(
- entity_reg, entry.entry_id
- ):
- for stay_out_zone_uid in removed_stay_out_zones:
- if entity_entry.unique_id.startswith(f"{mower_id}_{stay_out_zone_uid}"):
- entity_reg.async_remove(entity_entry.entity_id)
+ async_add_entities(
+ StayOutZoneSwitchEntity(coordinator, mower_id, zone_uid)
+ for zone_uid in stay_out_zone_uids
+ )
- def _async_stay_out_zone_listener() -> None:
- """Listen for new stay-out zones and add/remove switch entities if they did not exist."""
- for mower_id in coordinator.data:
+ def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None:
+ async_add_entities(
+ WorkAreaSwitchEntity(coordinator, mower_id, work_area_id)
+ for work_area_id in work_area_ids
+ )
+
+ def _async_add_new_devices(mower_ids: set[str]) -> None:
+ async_add_entities(
+ AutomowerScheduleSwitchEntity(mower_id, coordinator)
+ for mower_id in mower_ids
+ )
+ for mower_id in mower_ids:
+ mower_data = coordinator.data[mower_id]
if (
- coordinator.data[mower_id].capabilities.stay_out_zones
- and (_stay_out_zones := coordinator.data[mower_id].stay_out_zones)
- is not None
+ mower_data.capabilities.stay_out_zones
+ and mower_data.stay_out_zones is not None
+ and mower_data.stay_out_zones.zones is not None
):
- received_stay_out_zones = set(_stay_out_zones.zones)
- current_stay_out_zones_set = current_stay_out_zones.get(mower_id, set())
- new_stay_out_zones = (
- received_stay_out_zones - current_stay_out_zones_set
+ _async_add_new_stay_out_zones(
+ mower_id, set(mower_data.stay_out_zones.zones.keys())
)
- removed_stay_out_zones = (
- current_stay_out_zones_set - received_stay_out_zones
- )
- if new_stay_out_zones:
- current_stay_out_zones.setdefault(mower_id, set()).update(
- new_stay_out_zones
- )
- async_add_entities(
- StayOutZoneSwitchEntity(
- coordinator, mower_id, stay_out_zone_uid
- )
- for stay_out_zone_uid in new_stay_out_zones
- )
- if removed_stay_out_zones:
- _remove_stay_out_zone_entities(removed_stay_out_zones, mower_id)
+ if mower_data.capabilities.work_areas and mower_data.work_areas is not None:
+ _async_add_new_work_areas(mower_id, set(mower_data.work_areas.keys()))
- coordinator.async_add_listener(_async_work_area_listener)
- coordinator.async_add_listener(_async_stay_out_zone_listener)
- _async_work_area_listener()
- _async_stay_out_zone_listener()
+ coordinator.new_devices_callbacks.append(_async_add_new_devices)
+ coordinator.new_zones_callbacks.append(_async_add_new_stay_out_zones)
+ coordinator.new_areas_callbacks.append(_async_add_new_work_areas)
class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity):
@@ -185,14 +165,14 @@ class StayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.coordinator.api.commands.switch_stay_out_zone(
- self.mower_id, self.stay_out_zone_uid, False
+ self.mower_id, self.stay_out_zone_uid, switch=False
)
@handle_sending_exception(poll_after_sending=True)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.coordinator.api.commands.switch_stay_out_zone(
- self.mower_id, self.stay_out_zone_uid, True
+ self.mower_id, self.stay_out_zone_uid, switch=True
)
diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py
index 536b8f18259..d76ccef7cab 100644
--- a/homeassistant/components/hvv_departures/config_flow.py
+++ b/homeassistant/components/hvv_departures/config_flow.py
@@ -17,8 +17,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
-from homeassistant.helpers import aiohttp_client
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import aiohttp_client, config_validation as cv
from .const import CONF_FILTER, CONF_REAL_TIME, CONF_STATION, DOMAIN
from .hub import GTIHub
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
index ea5a5801e69..ee5a8a66610 100644
--- a/homeassistant/components/hydrawise/__init__.py
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -1,9 +1,9 @@
"""Support for Hydrawise cloud."""
-from pydrawise import auth, client
+from pydrawise import auth, hybrid
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -21,16 +21,21 @@ PLATFORMS: list[Platform] = [
Platform.VALVE,
]
+_REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY)
+
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Hydrawise from a config entry."""
- if CONF_USERNAME not in config_entry.data or CONF_PASSWORD not in config_entry.data:
- # The GraphQL API requires username and password to authenticate. If either is
- # missing, reauth is required.
+ if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS):
+ # If we are missing any required authentication keys, trigger a reauth flow.
raise ConfigEntryAuthFailed
- hydrawise = client.Hydrawise(
- auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]),
+ hydrawise = hybrid.HybridClient(
+ auth.HybridAuth(
+ config_entry.data[CONF_USERNAME],
+ config_entry.data[CONF_PASSWORD],
+ config_entry.data[CONF_API_KEY],
+ ),
app_id=APP_ID,
)
diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py
index 5af32af3951..3a61908ee2d 100644
--- a/homeassistant/components/hydrawise/config_flow.py
+++ b/homeassistant/components/hydrawise/config_flow.py
@@ -2,93 +2,130 @@
from __future__ import annotations
-from collections.abc import Callable, Mapping
+from collections.abc import Mapping
from typing import Any
from aiohttp import ClientError
-from pydrawise import auth as pydrawise_auth, client
+from pydrawise import auth as pydrawise_auth, hybrid
from pydrawise.exceptions import NotAuthorizedError
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from .const import APP_ID, DOMAIN, LOGGER
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Required(CONF_API_KEY): str,
+ }
+)
+STEP_REAUTH_DATA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_PASSWORD): str, vol.Required(CONF_API_KEY): str}
+)
+
class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hydrawise."""
VERSION = 1
-
- async def _create_or_update_entry(
- self,
- username: str,
- password: str,
- *,
- on_failure: Callable[[str], ConfigFlowResult],
- ) -> ConfigFlowResult:
- """Create the config entry."""
- # Verify that the provided credentials work."""
- auth = pydrawise_auth.Auth(username, password)
- try:
- await auth.token()
- except NotAuthorizedError:
- return on_failure("invalid_auth")
- except TimeoutError:
- return on_failure("timeout_connect")
-
- try:
- api = client.Hydrawise(auth, app_id=APP_ID)
- # Don't fetch zones because we don't need them yet.
- user = await api.get_user(fetch_zones=False)
- except TimeoutError:
- return on_failure("timeout_connect")
- except ClientError as ex:
- LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex)
- return on_failure("cannot_connect")
-
- await self.async_set_unique_id(f"hydrawise-{user.customer_id}")
-
- if self.source != SOURCE_REAUTH:
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title="Hydrawise",
- data={CONF_USERNAME: username, CONF_PASSWORD: password},
- )
-
- return self.async_update_reload_and_abort(
- self._get_reauth_entry(),
- data_updates={CONF_USERNAME: username, CONF_PASSWORD: password},
- )
+ MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial setup."""
- if user_input is not None:
- username = user_input[CONF_USERNAME]
- password = user_input[CONF_PASSWORD]
+ if user_input is None:
+ return self._show_user_form({})
+ username = user_input[CONF_USERNAME]
+ password = user_input[CONF_PASSWORD]
+ api_key = user_input[CONF_API_KEY]
+ unique_id, errors = await _authenticate(username, password, api_key)
+ if errors:
+ return self._show_user_form(errors)
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=username,
+ data={
+ CONF_USERNAME: username,
+ CONF_PASSWORD: password,
+ CONF_API_KEY: api_key,
+ },
+ )
- return await self._create_or_update_entry(
- username=username, password=password, on_failure=self._show_form
- )
- return self._show_form()
-
- def _show_form(self, error_type: str | None = None) -> ConfigFlowResult:
- errors = {}
- if error_type is not None:
- errors["base"] = error_type
+ def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult:
return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema(
- {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
- ),
- errors=errors,
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
- """Perform reauth after updating config to username/password."""
- return await self.async_step_user()
+ """Handle reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that reauth is required."""
+ if user_input is None:
+ return self._show_reauth_form({})
+
+ reauth_entry = self._get_reauth_entry()
+ username = reauth_entry.data[CONF_USERNAME]
+ password = user_input[CONF_PASSWORD]
+ api_key = user_input[CONF_API_KEY]
+ user_id, errors = await _authenticate(username, password, api_key)
+ if user_id is None:
+ return self._show_reauth_form(errors)
+
+ await self.async_set_unique_id(user_id)
+ self._abort_if_unique_id_mismatch(reason="wrong_account")
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data={
+ CONF_USERNAME: username,
+ CONF_PASSWORD: password,
+ CONF_API_KEY: api_key,
+ },
+ )
+
+ def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult:
+ return self.async_show_form(
+ step_id="reauth_confirm", data_schema=STEP_REAUTH_DATA_SCHEMA, errors=errors
+ )
+
+
+async def _authenticate(
+ username: str, password: str, api_key: str
+) -> tuple[str | None, dict[str, str]]:
+ """Authenticate with the Hydrawise API."""
+ unique_id = None
+ errors: dict[str, str] = {}
+ auth = pydrawise_auth.HybridAuth(username, password, api_key)
+ try:
+ await auth.check()
+ except NotAuthorizedError:
+ errors["base"] = "invalid_auth"
+ except TimeoutError:
+ errors["base"] = "timeout_connect"
+
+ if errors:
+ return unique_id, errors
+
+ try:
+ api = hybrid.HybridClient(auth, app_id=APP_ID)
+ # Don't fetch zones because we don't need them yet.
+ user = await api.get_user(fetch_zones=False)
+ except TimeoutError:
+ errors["base"] = "timeout_connect"
+ except ClientError as ex:
+ LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex)
+ errors["base"] = "cannot_connect"
+ else:
+ unique_id = f"hydrawise-{user.customer_id}"
+
+ return unique_id, errors
diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py
index e82a4ec1588..4721a9fb154 100644
--- a/homeassistant/components/hydrawise/coordinator.py
+++ b/homeassistant/components/hydrawise/coordinator.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
-from pydrawise import Hydrawise
+from pydrawise import HydrawiseBase
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
from homeassistant.core import HomeAssistant
@@ -38,7 +38,7 @@ class HydrawiseUpdateCoordinators:
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
"""Base class for Hydrawise Data Update Coordinators."""
- api: Hydrawise
+ api: HydrawiseBase
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
@@ -49,7 +49,7 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
integration are updated in a timely manner.
"""
- def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
+ def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
self.api = api
@@ -82,7 +82,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
def __init__(
self,
hass: HomeAssistant,
- api: Hydrawise,
+ api: HydrawiseBase,
main_coordinator: HydrawiseMainDataUpdateCoordinator,
) -> None:
"""Initialize HydrawiseWaterUseDataUpdateCoordinator."""
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index 50f803c07dc..de45eb061d5 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.12.0"]
+ "requirements": ["pydrawise==2025.1.0"]
}
diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json
index 4d50f10bcb2..47543aa2f8f 100644
--- a/homeassistant/components/hydrawise/strings.json
+++ b/homeassistant/components/hydrawise/strings.json
@@ -6,7 +6,22 @@
"description": "Please provide the username and password for your Hydrawise cloud account:",
"data": {
"username": "[%key:common::config_flow::data::username%]",
- "password": "[%key:common::config_flow::data::password%]"
+ "password": "[%key:common::config_flow::data::password%]",
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app"
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Hydrawise integration needs to re-authenticate your account",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]",
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::hydrawise::config::step::user::data_description::api_key%]"
}
}
},
diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py
index 1addaf1ec92..62cd81a0481 100644
--- a/homeassistant/components/hydrawise/switch.py
+++ b/homeassistant/components/hydrawise/switch.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import Any
-from pydrawise import Hydrawise, Zone
+from pydrawise import HydrawiseBase, Zone
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -28,8 +28,8 @@ from .entity import HydrawiseEntity
class HydrawiseSwitchEntityDescription(SwitchEntityDescription):
"""Describes Hydrawise binary sensor."""
- turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]]
- turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]]
+ turn_on_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]]
+ turn_off_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]]
value_fn: Callable[[Zone], bool]
diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py
index b2b7dbdf531..72e76ef8667 100644
--- a/homeassistant/components/hyperion/config_flow.py
+++ b/homeassistant/components/hyperion/config_flow.py
@@ -12,7 +12,6 @@ from urllib.parse import urlparse
from hyperion import client, const
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
@@ -29,7 +28,8 @@ from homeassistant.const import (
CONF_TOKEN,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo
from . import create_hyperion_client
from .const import (
@@ -155,7 +155,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._advance_to_auth_step_if_necessary(hyperion_client)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initiated by SSDP."""
# Sample data provided by SSDP: {
@@ -210,7 +210,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN):
except ValueError:
self._data[CONF_PORT] = const.DEFAULT_PORT_JSON
- if not (hyperion_id := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL)):
+ if not (hyperion_id := discovery_info.upnp.get(ATTR_UPNP_SERIAL)):
return self.async_abort(reason="no_id")
# For discovery mechanisms, we set the unique_id as early as possible to
diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py
index 5fa129ce7ad..40d093430a5 100644
--- a/homeassistant/components/hyperion/light.py
+++ b/homeassistant/components/hyperion/light.py
@@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import (
get_hyperion_device_id,
diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json
index 01682648277..ea7bc9e39fa 100644
--- a/homeassistant/components/hyperion/strings.json
+++ b/homeassistant/components/hyperion/strings.json
@@ -18,7 +18,7 @@
}
},
"create_token": {
- "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown id is \"{auth_id}\"",
+ "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown ID is \"{auth_id}\"",
"title": "Automatically create new authentication token"
},
"create_token_external": {
@@ -40,7 +40,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"auth_new_token_not_granted_error": "Newly created token was not approved on Hyperion UI",
"auth_new_token_not_work_error": "Failed to authenticate using newly created token",
- "no_id": "The Hyperion Ambilight instance did not report its id",
+ "no_id": "The Hyperion Ambilight instance did not report its ID",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py
index 1069c6696fc..047281bdb27 100644
--- a/homeassistant/components/iammeter/sensor.py
+++ b/homeassistant/components/iammeter/sensor.py
@@ -32,8 +32,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers import debounce, entity_registry as er, update_coordinator
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ debounce,
+ entity_registry as er,
+ update_coordinator,
+)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py
index c00398e39b0..5850a623ad8 100644
--- a/homeassistant/components/ibeacon/config_flow.py
+++ b/homeassistant/components/ibeacon/config_flow.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import VolDictType
from .const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN
diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json
index 8bd7e3ab9cc..bdbdaea49d2 100644
--- a/homeassistant/components/ibeacon/manifest.json
+++ b/homeassistant/components/ibeacon/manifest.json
@@ -7,7 +7,7 @@
"manufacturer_data_start": [2, 21]
}
],
- "codeowners": ["@bdraco"],
+ "codeowners": [],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ibeacon",
diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py
index 5bdfd00dc60..4ed66be6a4b 100644
--- a/homeassistant/components/icloud/__init__.py
+++ b/homeassistant/components/icloud/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.util import slugify
diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json
index 22c711e919a..adc96043d66 100644
--- a/homeassistant/components/icloud/strings.json
+++ b/homeassistant/components/icloud/strings.json
@@ -56,7 +56,7 @@
},
"play_sound": {
"name": "Play sound",
- "description": "Plays sound on an Apple device.",
+ "description": "Plays the Lost device sound on an Apple device.",
"fields": {
"account": {
"name": "Account",
@@ -64,7 +64,7 @@
},
"device_name": {
"name": "Device name",
- "description": "The name of the Apple device to play a sound."
+ "description": "The name of the Apple device to play the sound."
}
}
},
@@ -92,7 +92,7 @@
},
"lost_device": {
"name": "Lost device",
- "description": "Makes an Apple device in lost state.",
+ "description": "Puts an Apple device in lost state.",
"fields": {
"account": {
"name": "Account",
diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py
index 782d4988a3c..aa832fdfe48 100644
--- a/homeassistant/components/idasen_desk/config_flow.py
+++ b/homeassistant/components/idasen_desk/config_flow.py
@@ -87,7 +87,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json
index 7f44f8bbf44..9e83347f098 100644
--- a/homeassistant/components/idasen_desk/manifest.json
+++ b/homeassistant/components/idasen_desk/manifest.json
@@ -12,5 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
"integration_type": "device",
"iot_class": "local_push",
+ "quality_scale": "bronze",
"requirements": ["idasen-ha==2.6.3"]
}
diff --git a/homeassistant/components/idasen_desk/quality_scale.yaml b/homeassistant/components/idasen_desk/quality_scale.yaml
index 9aca846e32c..34bf97d9496 100644
--- a/homeassistant/components/idasen_desk/quality_scale.yaml
+++ b/homeassistant/components/idasen_desk/quality_scale.yaml
@@ -17,9 +17,9 @@ rules:
status: exempt
comment: |
This integration does not provide additional actions.
- docs-high-level-description: todo
+ docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
diff --git a/homeassistant/components/idteck_prox/__init__.py b/homeassistant/components/idteck_prox/__init__.py
index 7b92499a197..68969f1eced 100644
--- a/homeassistant/components/idteck_prox/__init__.py
+++ b/homeassistant/components/idteck_prox/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py
index e3db68e2302..c5682e5a8d9 100644
--- a/homeassistant/components/ifttt/__init__.py
+++ b/homeassistant/components/ifttt/__init__.py
@@ -15,8 +15,7 @@ from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py
index 739352485bd..f36fe8e672b 100644
--- a/homeassistant/components/ifttt/alarm_control_panel.py
+++ b/homeassistant/components/ifttt/alarm_control_panel.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_OPTIMISTIC,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py
index 0d20761c6e5..d356ad05541 100644
--- a/homeassistant/components/iglo/light.py
+++ b/homeassistant/components/iglo/light.py
@@ -20,10 +20,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
DEFAULT_NAME = "iGlo Light"
DEFAULT_PORT = 8080
diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py
new file mode 100644
index 00000000000..5e5e21452cf
--- /dev/null
+++ b/homeassistant/components/igloohome/__init__.py
@@ -0,0 +1,61 @@
+"""The igloohome integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from aiohttp import ClientError
+from igloohome_api import (
+ Api as IgloohomeApi,
+ ApiException,
+ Auth as IgloohomeAuth,
+ AuthException,
+ GetDeviceInfoResponse,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+@dataclass
+class IgloohomeRuntimeData:
+ """Holding class for runtime data."""
+
+ api: IgloohomeApi
+ devices: list[GetDeviceInfoResponse]
+
+
+type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
+ """Set up igloohome from a config entry."""
+
+ authentication = IgloohomeAuth(
+ session=async_get_clientsession(hass),
+ client_id=entry.data[CONF_CLIENT_ID],
+ client_secret=entry.data[CONF_CLIENT_SECRET],
+ )
+
+ api = IgloohomeApi(auth=authentication)
+ try:
+ devices = (await api.get_devices()).payload
+ except AuthException as e:
+ raise ConfigEntryError from e
+ except (ApiException, ClientError) as e:
+ raise ConfigEntryNotReady from e
+
+ entry.runtime_data = IgloohomeRuntimeData(api, devices)
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/igloohome/config_flow.py b/homeassistant/components/igloohome/config_flow.py
new file mode 100644
index 00000000000..a1d84900a03
--- /dev/null
+++ b/homeassistant/components/igloohome/config_flow.py
@@ -0,0 +1,61 @@
+"""Config flow for igloohome integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from aiohttp import ClientError
+from igloohome_api import Auth as IgloohomeAuth, AuthException
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_CLIENT_ID): str,
+ vol.Required(CONF_CLIENT_SECRET): str,
+ }
+)
+
+
+class IgloohomeConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for igloohome."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the config flow step."""
+
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ self._async_abort_entries_match(
+ {
+ CONF_CLIENT_ID: user_input[CONF_CLIENT_ID],
+ }
+ )
+ auth = IgloohomeAuth(
+ session=async_get_clientsession(self.hass),
+ client_id=user_input[CONF_CLIENT_ID],
+ client_secret=user_input[CONF_CLIENT_SECRET],
+ )
+ try:
+ await auth.async_get_access_token()
+ except AuthException:
+ errors["base"] = "invalid_auth"
+ except ClientError:
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_create_entry(
+ title="Client Credentials", data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/igloohome/const.py b/homeassistant/components/igloohome/const.py
new file mode 100644
index 00000000000..379c3bfbc1a
--- /dev/null
+++ b/homeassistant/components/igloohome/const.py
@@ -0,0 +1,3 @@
+"""Constants for the igloohome integration."""
+
+DOMAIN = "igloohome"
diff --git a/homeassistant/components/igloohome/entity.py b/homeassistant/components/igloohome/entity.py
new file mode 100644
index 00000000000..151cfbb3d2a
--- /dev/null
+++ b/homeassistant/components/igloohome/entity.py
@@ -0,0 +1,32 @@
+"""Implementation of a base entity that belongs to all igloohome devices."""
+
+from igloohome_api import Api as IgloohomeApi, GetDeviceInfoResponse
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+
+
+class IgloohomeBaseEntity(Entity):
+ """A base entity that is a part of all igloohome devices."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, unique_key: str
+ ) -> None:
+ """Initialize the base device class."""
+ self.api = api
+ self.api_device_info = api_device_info
+ # Register the entity as part of a device.
+ self._attr_device_info = dr.DeviceInfo(
+ identifiers={
+ # Serial numbers are unique identifiers within a specific domain
+ (DOMAIN, api_device_info.deviceId)
+ },
+ name=api_device_info.deviceName,
+ model=api_device_info.type,
+ )
+ # Set the unique ID of the entity.
+ self._attr_unique_id = f"{unique_key}_{api_device_info.deviceId}"
diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json
new file mode 100644
index 00000000000..35c58479d75
--- /dev/null
+++ b/homeassistant/components/igloohome/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "igloohome",
+ "name": "igloohome",
+ "codeowners": ["@keithle888"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/igloohome",
+ "iot_class": "cloud_polling",
+ "quality_scale": "bronze",
+ "requirements": ["igloohome-api==0.1.0"]
+}
diff --git a/homeassistant/components/igloohome/quality_scale.yaml b/homeassistant/components/igloohome/quality_scale.yaml
new file mode 100644
index 00000000000..432777cb729
--- /dev/null
+++ b/homeassistant/components/igloohome/quality_scale.yaml
@@ -0,0 +1,74 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ config-entry-unloading: done
+ action-exceptions:
+ status: exempt
+ comment: |
+ Integration has no actions and is a read-only platform.
+ docs-configuration-parameters: todo
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ No issues requiring a repair at the moment.
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/igloohome/sensor.py b/homeassistant/components/igloohome/sensor.py
new file mode 100644
index 00000000000..7f25798e454
--- /dev/null
+++ b/homeassistant/components/igloohome/sensor.py
@@ -0,0 +1,68 @@
+"""Implementation of the sensor platform."""
+
+from datetime import timedelta
+import logging
+
+from aiohttp import ClientError
+from igloohome_api import Api as IgloohomeApi, ApiException, GetDeviceInfoResponse
+
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import IgloohomeConfigEntry
+from .entity import IgloohomeBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+SCAN_INTERVAL = timedelta(hours=1)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: IgloohomeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up sensor entities."""
+
+ async_add_entities(
+ (
+ IgloohomeBatteryEntity(
+ api_device_info=device,
+ api=entry.runtime_data.api,
+ )
+ for device in entry.runtime_data.devices
+ if device.batteryLevel is not None
+ ),
+ update_before_add=True,
+ )
+
+
+class IgloohomeBatteryEntity(IgloohomeBaseEntity, SensorEntity):
+ """Implementation of a device that has a battery."""
+
+ _attr_native_unit_of_measurement = "%"
+ _attr_device_class = SensorDeviceClass.BATTERY
+
+ def __init__(
+ self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi
+ ) -> None:
+ """Initialize the class."""
+ super().__init__(
+ api_device_info=api_device_info,
+ api=api,
+ unique_key="battery",
+ )
+
+ async def async_update(self) -> None:
+ """Update the battery level."""
+ try:
+ response = await self.api.get_device_info(
+ deviceId=self.api_device_info.deviceId
+ )
+ except (ApiException, ClientError):
+ self._attr_available = False
+ else:
+ self._attr_available = True
+ self._attr_native_value = response.batteryLevel
diff --git a/homeassistant/components/igloohome/strings.json b/homeassistant/components/igloohome/strings.json
new file mode 100644
index 00000000000..463964c58ed
--- /dev/null
+++ b/homeassistant/components/igloohome/strings.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Copy & paste your [API access credentials](https://access.igloocompany.co/api-access) to give Home Assistant access to your account.",
+ "data": {
+ "client_id": "Client ID",
+ "client_secret": "Client secret"
+ },
+ "data_description": {
+ "client_id": "Client ID provided by your iglooaccess account.",
+ "client_secret": "Client Secret provided by your iglooaccess account."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ }
+ }
+}
diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py
index 7076d6a77a9..e99f2b23ca0 100644
--- a/homeassistant/components/ign_sismologia/geo_location.py
+++ b/homeassistant/components/ign_sismologia/geo_location.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfLength,
)
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import track_time_interval
diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py
index d443ac335db..0fc62301984 100644
--- a/homeassistant/components/ihc/__init__.py
+++ b/homeassistant/components/ihc/__init__.py
@@ -7,7 +7,7 @@ import voluptuous as vol
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .auto_setup import autosetup_ihc_products
diff --git a/homeassistant/components/ihc/auto_setup.py b/homeassistant/components/ihc/auto_setup.py
index 2d6e59131cd..9b711875167 100644
--- a/homeassistant/components/ihc/auto_setup.py
+++ b/homeassistant/components/ihc/auto_setup.py
@@ -9,8 +9,7 @@ import voluptuous as vol
from homeassistant.config import load_yaml_config_file
from homeassistant.const import CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from .const import (
AUTO_SETUP_YAML,
diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py
index f73c3079867..f90b2ee943c 100644
--- a/homeassistant/components/ihc/entity.py
+++ b/homeassistant/components/ihc/entity.py
@@ -43,7 +43,7 @@ class IHCEntity(Entity):
self.suggested_area = product.get("group")
if "id" in product:
product_id = product["id"]
- self.device_id = f"{controller_id}_{product_id }"
+ self.device_id = f"{controller_id}_{product_id}"
# this will name the device the same way as the IHC visual application: Product name + position
self.device_name = product["name"]
if self.ihc_position:
diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py
index c453494e263..f17920145e7 100644
--- a/homeassistant/components/ihc/manual_setup.py
+++ b/homeassistant/components/ihc/manual_setup.py
@@ -15,8 +15,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import (
diff --git a/homeassistant/components/ihc/service_functions.py b/homeassistant/components/ihc/service_functions.py
index 61eba4791ac..d5507328e73 100644
--- a/homeassistant/components/ihc/service_functions.py
+++ b/homeassistant/components/ihc/service_functions.py
@@ -3,7 +3,7 @@
import voluptuous as vol
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_CONTROLLER_ID,
diff --git a/homeassistant/components/ihc/strings.json b/homeassistant/components/ihc/strings.json
index af2152a88bb..04daef83c9d 100644
--- a/homeassistant/components/ihc/strings.json
+++ b/homeassistant/components/ihc/strings.json
@@ -6,7 +6,7 @@
"fields": {
"controller_id": {
"name": "Controller ID",
- "description": "If you have multiple controller, this is the index of you controller\nstarting with 0.\n."
+ "description": "If you have multiple controllers, this is the index of your controller, starting with 0."
},
"ihc_id": {
"name": "IHC ID",
diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py
index dbb5962eabf..644d335bbca 100644
--- a/homeassistant/components/image/__init__.py
+++ b/homeassistant/components/image/__init__.py
@@ -14,7 +14,7 @@ from typing import Final, final
from aiohttp import hdrs, web
import httpx
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
@@ -28,7 +28,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import (
diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py
index 0ac8d39813b..06b6bb7a57f 100644
--- a/homeassistant/components/image_processing/__init__.py
+++ b/homeassistant/components/image_processing/__init__.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py
index 5e9cf8c4e0e..2bf28d13fd2 100644
--- a/homeassistant/components/image_upload/__init__.py
+++ b/homeassistant/components/image_upload/__init__.py
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json
index bb8c33ba749..e43377a3230 100644
--- a/homeassistant/components/image_upload/manifest.json
+++ b/homeassistant/components/image_upload/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["Pillow==11.0.0"]
+ "requirements": ["Pillow==11.1.0"]
}
diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py
index f62edf1451f..5349f249ab3 100644
--- a/homeassistant/components/imap/__init__.py
+++ b/homeassistant/components/imap/__init__.py
@@ -23,7 +23,7 @@ from homeassistant.exceptions import (
ConfigEntryNotReady,
ServiceValidationError,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_ENABLE_PUSH, DOMAIN
diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json
index b058a3d50f4..515fee0e721 100644
--- a/homeassistant/components/imap/manifest.json
+++ b/homeassistant/components/imap/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/imap",
"iot_class": "cloud_push",
"loggers": ["aioimaplib"],
- "requirements": ["aioimaplib==1.1.0"]
+ "requirements": ["aioimaplib==2.0.1"]
}
diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py
index caf4e058e06..eb12e1a2bb4 100644
--- a/homeassistant/components/imgw_pib/__init__.py
+++ b/homeassistant/components/imgw_pib/__init__.py
@@ -9,16 +9,18 @@ from aiohttp import ClientError
from imgw_pib import ImgwPib
from imgw_pib.exceptions import ApiError
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import CONF_STATION_ID
+from .const import CONF_STATION_ID, DOMAIN
from .coordinator import ImgwPibDataUpdateCoordinator
-PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
@@ -42,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b
try:
imgwpib = await ImgwPib.create(
- client_session, hydrological_station_id=station_id
+ client_session,
+ hydrological_station_id=station_id,
+ hydrological_details=False,
)
except (ClientError, TimeoutError, ApiError) as err:
raise ConfigEntryNotReady from err
@@ -50,6 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b
coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id)
await coordinator.async_config_entry_first_refresh()
+ # Remove binary_sensor entities for which the endpoint has been blocked by IMGW-PIB API
+ entity_reg = er.async_get(hass)
+ for key in ("flood_warning", "flood_alarm"):
+ if entity_id := entity_reg.async_get_entity_id(
+ BINARY_SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}"
+ ):
+ entity_reg.async_remove(entity_id)
+
entry.runtime_data = ImgwPibData(coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/imgw_pib/binary_sensor.py b/homeassistant/components/imgw_pib/binary_sensor.py
deleted file mode 100644
index 1c4cc738f8f..00000000000
--- a/homeassistant/components/imgw_pib/binary_sensor.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""IMGW-PIB binary sensor platform."""
-
-from __future__ import annotations
-
-from collections.abc import Callable
-from dataclasses import dataclass
-
-from imgw_pib.model import HydrologicalData
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDeviceClass,
- BinarySensorEntity,
- BinarySensorEntityDescription,
-)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from . import ImgwPibConfigEntry
-from .coordinator import ImgwPibDataUpdateCoordinator
-from .entity import ImgwPibEntity
-
-PARALLEL_UPDATES = 1
-
-
-@dataclass(frozen=True, kw_only=True)
-class ImgwPibBinarySensorEntityDescription(BinarySensorEntityDescription):
- """IMGW-PIB sensor entity description."""
-
- value: Callable[[HydrologicalData], bool | None]
-
-
-BINARY_SENSOR_TYPES: tuple[ImgwPibBinarySensorEntityDescription, ...] = (
- ImgwPibBinarySensorEntityDescription(
- key="flood_warning",
- translation_key="flood_warning",
- device_class=BinarySensorDeviceClass.SAFETY,
- value=lambda data: data.flood_warning,
- ),
- ImgwPibBinarySensorEntityDescription(
- key="flood_alarm",
- translation_key="flood_alarm",
- device_class=BinarySensorDeviceClass.SAFETY,
- value=lambda data: data.flood_alarm,
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ImgwPibConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Add a IMGW-PIB binary sensor entity from a config_entry."""
- coordinator = entry.runtime_data.coordinator
-
- async_add_entities(
- ImgwPibBinarySensorEntity(coordinator, description)
- for description in BINARY_SENSOR_TYPES
- if getattr(coordinator.data, description.key) is not None
- )
-
-
-class ImgwPibBinarySensorEntity(ImgwPibEntity, BinarySensorEntity):
- """Define IMGW-PIB binary sensor entity."""
-
- entity_description: ImgwPibBinarySensorEntityDescription
-
- def __init__(
- self,
- coordinator: ImgwPibDataUpdateCoordinator,
- description: ImgwPibBinarySensorEntityDescription,
- ) -> None:
- """Initialize."""
- super().__init__(coordinator)
-
- self._attr_unique_id = f"{coordinator.station_id}_{description.key}"
- self.entity_description = description
-
- @property
- def is_on(self) -> bool | None:
- """Return true if the binary sensor is on."""
- return self.entity_description.value(self.coordinator.data)
diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json
index bf8608ae21b..29aa19a4b56 100644
--- a/homeassistant/components/imgw_pib/icons.json
+++ b/homeassistant/components/imgw_pib/icons.json
@@ -1,26 +1,6 @@
{
"entity": {
- "binary_sensor": {
- "flood_warning": {
- "default": "mdi:check-circle",
- "state": {
- "on": "mdi:home-flood"
- }
- },
- "flood_alarm": {
- "default": "mdi:check-circle",
- "state": {
- "on": "mdi:home-flood"
- }
- }
- },
"sensor": {
- "flood_warning_level": {
- "default": "mdi:alert-outline"
- },
- "flood_alarm_level": {
- "default": "mdi:alert"
- },
"water_level": {
"default": "mdi:waves"
},
diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json
index ce3bc14d37b..0ecc1b4b7d0 100644
--- a/homeassistant/components/imgw_pib/manifest.json
+++ b/homeassistant/components/imgw_pib/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
- "requirements": ["imgw_pib==1.0.7"]
+ "requirements": ["imgw_pib==1.0.9"]
}
diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py
index f000222b31b..15043af2015 100644
--- a/homeassistant/components/imgw_pib/sensor.py
+++ b/homeassistant/components/imgw_pib/sensor.py
@@ -8,17 +8,20 @@ from dataclasses import dataclass
from imgw_pib.model import HydrologicalData
from homeassistant.components.sensor import (
+ DOMAIN as SENSOR_PLATFORM,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import EntityCategory, UnitOfLength, UnitOfTemperature
+from homeassistant.const import UnitOfLength, UnitOfTemperature
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import ImgwPibConfigEntry
+from .const import DOMAIN
from .coordinator import ImgwPibDataUpdateCoordinator
from .entity import ImgwPibEntity
@@ -33,26 +36,6 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = (
- ImgwPibSensorEntityDescription(
- key="flood_alarm_level",
- translation_key="flood_alarm_level",
- native_unit_of_measurement=UnitOfLength.CENTIMETERS,
- device_class=SensorDeviceClass.DISTANCE,
- entity_category=EntityCategory.DIAGNOSTIC,
- suggested_display_precision=0,
- entity_registry_enabled_default=False,
- value=lambda data: data.flood_alarm_level.value,
- ),
- ImgwPibSensorEntityDescription(
- key="flood_warning_level",
- translation_key="flood_warning_level",
- native_unit_of_measurement=UnitOfLength.CENTIMETERS,
- device_class=SensorDeviceClass.DISTANCE,
- entity_category=EntityCategory.DIAGNOSTIC,
- suggested_display_precision=0,
- entity_registry_enabled_default=False,
- value=lambda data: data.flood_warning_level.value,
- ),
ImgwPibSensorEntityDescription(
key="water_level",
translation_key="water_level",
@@ -82,6 +65,14 @@ async def async_setup_entry(
"""Add a IMGW-PIB sensor entity from a config_entry."""
coordinator = entry.runtime_data.coordinator
+ # Remove entities for which the endpoint has been blocked by IMGW-PIB API
+ entity_reg = er.async_get(hass)
+ for key in ("flood_warning_level", "flood_alarm_level"):
+ if entity_id := entity_reg.async_get_entity_id(
+ SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}"
+ ):
+ entity_reg.async_remove(entity_id)
+
async_add_entities(
ImgwPibSensorEntity(coordinator, description)
for description in SENSOR_TYPES
diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json
index 6bc337d5720..9a17dcf7087 100644
--- a/homeassistant/components/imgw_pib/strings.json
+++ b/homeassistant/components/imgw_pib/strings.json
@@ -17,21 +17,7 @@
}
},
"entity": {
- "binary_sensor": {
- "flood_alarm": {
- "name": "Flood alarm"
- },
- "flood_warning": {
- "name": "Flood warning"
- }
- },
"sensor": {
- "flood_alarm_level": {
- "name": "Flood alarm level"
- },
- "flood_warning_level": {
- "name": "Flood warning level"
- },
"water_level": {
"name": "Water level"
},
diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py
index 05dd1de449a..22f2bf3623c 100644
--- a/homeassistant/components/improv_ble/config_flow.py
+++ b/homeassistant/components/improv_ble/config_flow.py
@@ -126,15 +126,23 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
)
except improv_ble_errors.InvalidCommand as err:
_LOGGER.warning(
- "Aborting improv flow, device %s sent invalid improv data: '%s'",
- self._discovery_info.address,
+ (
+ "Received invalid improv via BLE data '%s' from device with "
+ "bluetooth address '%s'; if the device is a self-configured "
+ "ESPHome device, either correct or disable the 'esp32_improv' "
+ "configuration; if it's a commercial device, contact the vendor"
+ ),
service_data[SERVICE_DATA_UUID].hex(),
+ self._discovery_info.address,
)
raise AbortFlow("invalid_improv_data") from err
if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED):
_LOGGER.debug(
- "Aborting improv flow, device %s is already provisioned: %s",
+ (
+ "Aborting improv flow, device with bluetooth address '%s' is "
+ "already provisioned: %s"
+ ),
self._discovery_info.address,
improv_service_data.state,
)
diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py
index 4b6a6a5fcc3..4d05a57bcfa 100644
--- a/homeassistant/components/incomfort/__init__.py
+++ b/homeassistant/components/incomfort/__init__.py
@@ -3,15 +3,17 @@
from __future__ import annotations
from aiohttp import ClientResponseError
-from incomfortclient import IncomfortError, InvalidHeaterList
+from incomfortclient import InvalidGateway, InvalidHeaterList
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
-from .coordinator import InComfortDataCoordinator, async_connect_gateway
-from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound
+from .const import DOMAIN
+from .coordinator import InComfortData, InComfortDataCoordinator, async_connect_gateway
+from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound
PLATFORMS = (
Platform.WATER_HEATER,
@@ -25,7 +27,44 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+@callback
+def async_cleanup_stale_devices(
+ hass: HomeAssistant,
+ entry: InComfortConfigEntry,
+ data: InComfortData,
+ gateway_device: dr.DeviceEntry,
+) -> None:
+ """Cleanup stale heater devices and climates."""
+ heater_serial_numbers = {heater.serial_no for heater in data.heaters}
+ device_registry = dr.async_get(hass)
+ device_entries = device_registry.devices.get_devices_for_config_entry_id(
+ entry.entry_id
+ )
+ stale_heater_serial_numbers: list[str] = [
+ device_entry.serial_number
+ for device_entry in device_entries
+ if device_entry.id != gateway_device.id
+ and device_entry.serial_number is not None
+ and device_entry.serial_number not in heater_serial_numbers
+ ]
+ if not stale_heater_serial_numbers:
+ return
+ cleanup_devices: list[str] = []
+ # Find stale heater and climate devices
+ for serial_number in stale_heater_serial_numbers:
+ cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)]
+ cleanup_list.append(serial_number)
+ cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list]
+ cleanup_devices.extend(
+ device_entry.id
+ for device_entry in device_entries
+ if device_entry.identifiers in cleanup_identifiers
+ )
+ for device_id in cleanup_devices:
+ device_registry.async_remove_device(device_id)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool:
"""Set up a config entry."""
try:
data = await async_connect_gateway(hass, dict(entry.data))
@@ -33,17 +72,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await heater.update()
except InvalidHeaterList as exc:
raise NoHeaters from exc
- except IncomfortError as exc:
- if isinstance(exc.message, ClientResponseError):
- if exc.message.status == 401:
- raise ConfigEntryAuthFailed("Incorrect credentials") from exc
- if exc.message.status == 404:
- raise NotFound from exc
- raise InConfortUnknownError from exc
+ except InvalidGateway as exc:
+ raise ConfigEntryAuthFailed("Incorrect credentials") from exc
+ except ClientResponseError as exc:
+ if exc.status == 404:
+ raise NotFound from exc
+ raise InComfortUnknownError from exc
except TimeoutError as exc:
- raise InConfortTimeout from exc
+ raise InComfortTimeout from exc
- coordinator = InComfortDataCoordinator(hass, data)
+ # Register discovered gateway device
+ device_registry = dr.async_get(hass)
+ gateway_device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.entry_id)},
+ connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
+ if entry.unique_id is not None
+ else set(),
+ manufacturer="Intergas",
+ name="RFGateway",
+ )
+ async_cleanup_stale_devices(hass, entry, data, gateway_device)
+ coordinator = InComfortDataCoordinator(hass, data, entry.entry_id)
entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
@@ -51,6 +101,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool:
"""Unload config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py
index a94e1fac504..e4353e457a5 100644
--- a/homeassistant/components/incomfort/binary_sensor.py
+++ b/homeassistant/components/incomfort/binary_sensor.py
@@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -20,6 +21,8 @@ from . import InComfortConfigEntry
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -27,6 +30,7 @@ class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription):
value_key: str
extra_state_attributes_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None
+ entity_category: EntityCategory = EntityCategory.DIAGNOSTIC
SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = (
@@ -38,24 +42,28 @@ SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = (
extra_state_attributes_fn=lambda status: {
"fault_code": status["fault_code"] or "none",
},
+ entity_registry_enabled_default=False,
),
IncomfortBinarySensorEntityDescription(
key="is_pumping",
translation_key="is_pumping",
device_class=BinarySensorDeviceClass.RUNNING,
value_key="is_pumping",
+ entity_registry_enabled_default=False,
),
IncomfortBinarySensorEntityDescription(
key="is_burning",
translation_key="is_burning",
device_class=BinarySensorDeviceClass.RUNNING,
value_key="is_burning",
+ entity_registry_enabled_default=False,
),
IncomfortBinarySensorEntityDescription(
key="is_tapping",
translation_key="is_tapping",
device_class=BinarySensorDeviceClass.RUNNING,
value_key="is_tapping",
+ entity_registry_enabled_default=False,
),
)
@@ -94,7 +102,7 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return the status of the sensor."""
- return self._heater.status[self.entity_description.value_key]
+ return bool(self._heater.status[self.entity_description.value_key])
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py
index 41470180051..f814b1fb1f5 100644
--- a/homeassistant/components/incomfort/climate.py
+++ b/homeassistant/components/incomfort/climate.py
@@ -12,16 +12,18 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import InComfortConfigEntry
-from .const import DOMAIN
+from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortEntity
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -30,15 +32,19 @@ async def async_setup_entry(
) -> None:
"""Set up InComfort/InTouch climate devices."""
incomfort_coordinator = entry.runtime_data
+ legacy_setpoint_status = entry.options.get(CONF_LEGACY_SETPOINT_STATUS, False)
heaters = incomfort_coordinator.data.heaters
async_add_entities(
- InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms
+ InComfortClimate(incomfort_coordinator, h, r, legacy_setpoint_status)
+ for h in heaters
+ for r in h.rooms
)
class InComfortClimate(IncomfortEntity, ClimateEntity):
"""Representation of an InComfort/InTouch climate device."""
+ _attr_entity_category = EntityCategory.CONFIG
_attr_min_temp = 5.0
_attr_max_temp = 30.0
_attr_name = None
@@ -52,12 +58,14 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
coordinator: InComfortDataCoordinator,
heater: InComfortHeater,
room: InComfortRoom,
+ legacy_setpoint_status: bool,
) -> None:
"""Initialize the climate device."""
super().__init__(coordinator)
self._heater = heater
self._room = room
+ self._legacy_setpoint_status = legacy_setpoint_status
self._attr_unique_id = f"{heater.serial_no}_{room.room_no}"
self._attr_device_info = DeviceInfo(
@@ -65,6 +73,8 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
manufacturer="Intergas",
name=f"Thermostat {room.room_no}",
)
+ if coordinator.unique_id:
+ self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -89,14 +99,16 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
As we set the override, we report back the override. The actual set point is
is returned at a later time.
- Some older thermostats return 0.0 as override, in that case we fallback to
- the actual setpoint.
+ Some older thermostats do not clear the override setting in that case, in that case
+ we fallback to the returning actual setpoint.
"""
+ if self._legacy_setpoint_status:
+ return self._room.setpoint
return self._room.override or self._room.setpoint
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature for this zone."""
- temperature = kwargs.get(ATTR_TEMPERATURE)
+ temperature: float = kwargs[ATTR_TEMPERATURE]
await self._room.set_override(temperature)
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py
index f4838a9771d..8e4a5f72619 100644
--- a/homeassistant/components/incomfort/config_flow.py
+++ b/homeassistant/components/incomfort/config_flow.py
@@ -1,21 +1,35 @@
"""Config flow support for Intergas InComfort integration."""
+from __future__ import annotations
+
+from collections.abc import Mapping
from typing import Any
-from aiohttp import ClientResponseError
-from incomfortclient import IncomfortError, InvalidHeaterList
+from incomfortclient import InvalidGateway, InvalidHeaterList
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
+ BooleanSelector,
+ BooleanSelectorConfig,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import DOMAIN
+from . import InComfortConfigEntry
+from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import async_connect_gateway
TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
@@ -34,10 +48,33 @@ CONFIG_SCHEMA = vol.Schema(
}
)
-ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = {
- 401: (CONF_PASSWORD, "auth_error"),
- 404: ("base", "not_found"),
-}
+DHCP_CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_USERNAME): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin")
+ ),
+ vol.Optional(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ }
+)
+
+REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ }
+)
+
+
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector(
+ BooleanSelectorConfig()
+ )
+ }
+)
async def async_try_connect_gateway(
@@ -46,15 +83,10 @@ async def async_try_connect_gateway(
"""Try to connect to the Lan2RF gateway."""
try:
await async_connect_gateway(hass, config)
+ except InvalidGateway:
+ return {"base": "auth_error"}
except InvalidHeaterList:
return {"base": "no_heaters"}
- except IncomfortError as exc:
- if isinstance(exc.message, ClientResponseError):
- scope, error = ERROR_STATUS_MAPPING.get(
- exc.message.status, ("base", "unknown")
- )
- return {scope: error}
- return {"base": "unknown"}
except TimeoutError:
return {"base": "timeout_error"}
except Exception: # noqa: BLE001
@@ -66,18 +98,165 @@ async def async_try_connect_gateway(
class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to set up an Intergas InComfort boyler and thermostats."""
+ _discovered_host: str
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: InComfortConfigEntry,
+ ) -> InComfortOptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return InComfortOptionsFlowHandler()
+
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Prepare configuration for a DHCP discovered Intergas Gateway device."""
+ self._discovered_host = discovery_info.ip
+ # In case we have an existing entry with the same host
+ # we update the entry with the unique_id for the gateway, and abort the flow
+ unique_id = format_mac(discovery_info.macaddress)
+ existing_entries_without_unique_id = [
+ entry
+ for entry in self._async_current_entries(include_ignore=False)
+ if entry.unique_id is None
+ and entry.data.get(CONF_HOST) == self._discovered_host
+ and entry.state is ConfigEntryState.LOADED
+ ]
+ if existing_entries_without_unique_id:
+ self.hass.config_entries.async_update_entry(
+ existing_entries_without_unique_id[0], unique_id=unique_id
+ )
+ self.hass.config_entries.async_schedule_reload(
+ existing_entries_without_unique_id[0].entry_id
+ )
+ raise AbortFlow("already_configured")
+
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: self._discovered_host})
+
+ return await self.async_step_dhcp_confirm()
+
+ async def async_step_dhcp_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm setup from discovery."""
+ if user_input is not None:
+ return await self.async_step_dhcp_auth({CONF_HOST: self._discovered_host})
+ return self.async_show_form(
+ step_id="dhcp_confirm",
+ description_placeholders={CONF_HOST: self._discovered_host},
+ )
+
+ async def async_step_dhcp_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial set up via DHCP."""
+ errors: dict[str, str] | None = None
+ data_schema: vol.Schema = DHCP_CONFIG_SCHEMA
+ if user_input is not None:
+ user_input[CONF_HOST] = self._discovered_host
+ if (
+ errors := await async_try_connect_gateway(self.hass, user_input)
+ ) is None:
+ return self.async_create_entry(title=TITLE, data=user_input)
+ data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
+
+ return self.async_show_form(
+ step_id="dhcp_auth",
+ data_schema=data_schema,
+ errors=errors,
+ description_placeholders={CONF_HOST: self._discovered_host},
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] | None = None
+ data_schema: vol.Schema = CONFIG_SCHEMA
+ if is_reconfigure := (self.source == SOURCE_RECONFIGURE):
+ reconfigure_entry = self._get_reconfigure_entry()
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, reconfigure_entry.data
+ )
if user_input is not None:
- self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if (
- errors := await async_try_connect_gateway(self.hass, user_input)
+ errors := await async_try_connect_gateway(
+ self.hass,
+ (reconfigure_entry.data | user_input)
+ if is_reconfigure
+ else user_input,
+ )
) is None:
+ if is_reconfigure:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry, data_updates=user_input
+ )
+ self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
return self.async_create_entry(title=TITLE, data=user_input)
+ data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
return self.async_show_form(
- step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
+ step_id="user", data_schema=data_schema, errors=errors
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle re-authentication and confirmation."""
+ errors: dict[str, str] | None = None
+
+ if user_input:
+ password: str = user_input[CONF_PASSWORD]
+
+ reauth_entry = self._get_reauth_entry()
+ errors = await async_try_connect_gateway(
+ self.hass, reauth_entry.data | {CONF_PASSWORD: password}
+ )
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reauth_entry, data_updates={CONF_PASSWORD: password}
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration flow."""
+ return await self.async_step_user()
+
+
+class InComfortOptionsFlowHandler(OptionsFlow):
+ """Handle InComfort Lan2RF gateway options."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ errors: dict[str, str] | None = None
+ if user_input is not None:
+ new_options: dict[str, Any] = self.config_entry.options | user_input
+ self.hass.config_entries.async_update_entry(
+ self.config_entry, options=new_options
+ )
+ self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
+ return self.async_create_entry(data=new_options)
+
+ data_schema = self.add_suggested_values_to_schema(
+ OPTIONS_SCHEMA, self.config_entry.options
+ )
+ return self.async_show_form(
+ step_id="init",
+ data_schema=data_schema,
+ errors=errors,
)
diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py
index 721dd8591b0..b3b9312acd6 100644
--- a/homeassistant/components/incomfort/const.py
+++ b/homeassistant/components/incomfort/const.py
@@ -1,3 +1,5 @@
"""Constants for Intergas InComfort integration."""
DOMAIN = "incomfort"
+
+CONF_LEGACY_SETPOINT_STATUS = "legacy_setpoint_status"
diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py
index a5c8da0c208..3436d40298a 100644
--- a/homeassistant/components/incomfort/coordinator.py
+++ b/homeassistant/components/incomfort/coordinator.py
@@ -9,7 +9,7 @@ from aiohttp import ClientResponseError
from incomfortclient import (
Gateway as InComfortGateway,
Heater as InComfortHeater,
- IncomfortError,
+ InvalidHeaterList,
)
from homeassistant.const import CONF_HOST
@@ -50,8 +50,11 @@ async def async_connect_gateway(
class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
"""Data coordinator for InComfort entities."""
- def __init__(self, hass: HomeAssistant, incomfort_data: InComfortData) -> None:
+ def __init__(
+ self, hass: HomeAssistant, incomfort_data: InComfortData, unique_id: str | None
+ ) -> None:
"""Initialize coordinator."""
+ self.unique_id = unique_id
super().__init__(
hass,
_LOGGER,
@@ -66,10 +69,11 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
for heater in self.incomfort_data.heaters:
await heater.update()
except TimeoutError as exc:
- raise UpdateFailed from exc
- except IncomfortError as exc:
- if isinstance(exc.message, ClientResponseError):
- if exc.message.status == 401:
- raise ConfigEntryError("Incorrect credentials") from exc
- raise UpdateFailed from exc
+ raise UpdateFailed("Timeout error") from exc
+ except ClientResponseError as exc:
+ if exc.status == 401:
+ raise ConfigEntryError("Incorrect credentials") from exc
+ raise UpdateFailed(exc.message) from exc
+ except InvalidHeaterList as exc:
+ raise UpdateFailed(exc.message) from exc
return self.incomfort_data
diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py
new file mode 100644
index 00000000000..a2f89a94f58
--- /dev/null
+++ b/homeassistant/components/incomfort/diagnostics.py
@@ -0,0 +1,45 @@
+"""Diagnostics support for InComfort integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_PASSWORD
+from homeassistant.core import HomeAssistant, callback
+
+from . import InComfortConfigEntry
+
+REDACT_CONFIG = {CONF_PASSWORD}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: InComfortConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ return _async_get_diagnostics(hass, entry)
+
+
+@callback
+def _async_get_diagnostics(
+ hass: HomeAssistant,
+ entry: InComfortConfigEntry,
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG)
+ coordinator = entry.runtime_data
+
+ nr_heaters = len(coordinator.incomfort_data.heaters)
+ status: dict[str, Any] = {
+ f"heater_{n}": coordinator.incomfort_data.heaters[n].status
+ for n in range(nr_heaters)
+ }
+ for n in range(nr_heaters):
+ status[f"heater_{n}"]["rooms"] = {
+ m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status)
+ for m in range(len(coordinator.incomfort_data.heaters[n].rooms))
+ }
+ return {
+ "config": redacted_config,
+ "gateway": status,
+ }
diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py
index 33037a78edf..1924c91376b 100644
--- a/homeassistant/components/incomfort/entity.py
+++ b/homeassistant/components/incomfort/entity.py
@@ -26,4 +26,7 @@ class IncomfortBoilerEntity(IncomfortEntity):
identifiers={(DOMAIN, heater.serial_no)},
manufacturer="Intergas",
name="Boiler",
+ serial_number=heater.serial_no,
)
+ if coordinator.unique_id:
+ self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id)
diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py
index 93a29d05bb8..c367916d6c7 100644
--- a/homeassistant/components/incomfort/errors.py
+++ b/homeassistant/components/incomfort/errors.py
@@ -1,32 +1,33 @@
"""Exceptions raised by Intergas InComfort integration."""
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
+from .const import DOMAIN
+
class NotFound(HomeAssistantError):
"""Raise exception if no Lan2RF Gateway was found."""
- translation_domain = HOMEASSISTANT_DOMAIN
+ translation_domain = DOMAIN
translation_key = "not_found"
class NoHeaters(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
- translation_domain = HOMEASSISTANT_DOMAIN
+ translation_domain = DOMAIN
translation_key = "no_heaters"
-class InConfortTimeout(ConfigEntryNotReady):
+class InComfortTimeout(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
- translation_domain = HOMEASSISTANT_DOMAIN
+ translation_domain = DOMAIN
translation_key = "timeout_error"
-class InConfortUnknownError(ConfigEntryNotReady):
+class InComfortUnknownError(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
- translation_domain = HOMEASSISTANT_DOMAIN
+ translation_domain = DOMAIN
translation_key = "unknown"
diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json
index f404f33b970..d02b1d27554 100644
--- a/homeassistant/components/incomfort/manifest.json
+++ b/homeassistant/components/incomfort/manifest.json
@@ -1,10 +1,14 @@
{
"domain": "incomfort",
- "name": "Intergas InComfort/Intouch Lan2RF gateway",
+ "name": "Intergas gateway",
"codeowners": ["@jbouwh"],
"config_flow": true,
+ "dhcp": [
+ { "hostname": "rfgateway", "macaddress": "0004A3*" },
+ { "registered_devices": true }
+ ],
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
- "requirements": ["incomfort-client==0.6.4"]
+ "requirements": ["incomfort-client==0.6.7"]
}
diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py
index e0d6740f1d4..e3f3fc785b2 100644
--- a/homeassistant/components/incomfort/sensor.py
+++ b/homeassistant/components/incomfort/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import UnitOfPressure, UnitOfTemperature
+from homeassistant.const import EntityCategory, UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -22,6 +22,8 @@ from . import InComfortConfigEntry
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class IncomfortSensorEntityDescription(SensorEntityDescription):
@@ -29,6 +31,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription):
value_key: str
extra_key: str | None = None
+ entity_category = EntityCategory.DIAGNOSTIC
SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
@@ -38,6 +41,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
value_key="pressure",
+ entity_registry_enabled_default=False,
),
IncomfortSensorEntityDescription(
key="cv_temp",
@@ -46,6 +50,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
extra_key="is_pumping",
value_key="heater_temp",
+ entity_registry_enabled_default=False,
),
IncomfortSensorEntityDescription(
key="tap_temp",
@@ -55,6 +60,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
extra_key="is_tapping",
value_key="tap_temp",
+ entity_registry_enabled_default=False,
),
)
@@ -93,7 +99,7 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
- return self._heater.status[self.entity_description.value_key]
+ return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json
index a2bb874142b..15e28b6e0b9 100644
--- a/homeassistant/components/incomfort/strings.json
+++ b/homeassistant/components/incomfort/strings.json
@@ -2,55 +2,82 @@
"config": {
"step": {
"user": {
- "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.",
+ "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.",
+ "host": "Hostname or IP-address of the Intergas gateway.",
"username": "The username to log into the gateway. This is `admin` in most cases.",
- "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices."
+ "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices."
}
+ },
+ "dhcp_auth": {
+ "title": "Set up Intergas gateway",
+ "description": "Please enter authentication details for gateway {host}",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The username to log into the gateway. This is `admin` in most cases.",
+ "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices."
+ }
+ },
+ "dhcp_confirm": {
+ "title": "Set up Intergas gateway",
+ "description": "Do you want to set up the discovered Intergas gateway ({host})?"
+ },
+ "reauth_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Correct the gateway password."
+ },
+ "description": "Re-authenticate to the gateway."
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
- "auth_error": "Invalid credentials.",
- "no_heaters": "No heaters found.",
- "not_found": "No Lan2RF gateway found.",
- "timeout_error": "Time out when connection to Lan2RF gateway.",
- "unknown": "Unknown error when connection to Lan2RF gateway."
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
- "auth_error": "[%key:component::incomfort::config::abort::auth_error%]",
- "no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]",
- "not_found": "[%key:component::incomfort::config::abort::not_found%]",
- "timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]",
- "unknown": "[%key:component::incomfort::config::abort::unknown%]"
+ "auth_error": "Invalid credentials.",
+ "no_heaters": "No heaters found.",
+ "not_found": "No gateway found.",
+ "timeout_error": "Time out when connecting to the gateway.",
+ "unknown": "Unknown error when connecting to the gateway."
}
},
- "issues": {
- "deprecated_yaml_import_issue_unknown": {
- "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.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
+ "exceptions": {
+ "no_heaters": {
+ "message": "[%key:component::incomfort::config::error::no_heaters%]"
},
- "deprecated_yaml_import_issue_auth_error": {
- "title": "YAML import failed due to an authentication error",
- "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
+ "not_found": {
+ "message": "[%key:component::incomfort::config::error::not_found%]"
},
- "deprecated_yaml_import_issue_no_heaters": {
- "title": "YAML import failed because no heaters were found",
- "description": "Configuring {integration_title} using YAML is being removed but no heaters were found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
+ "timeout_error": {
+ "message": "[%key:component::incomfort::config::error::timeout_error%]"
},
- "deprecated_yaml_import_issue_not_found": {
- "title": "YAML import failed because no gateway was found",
- "description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
- },
- "deprecated_yaml_import_issue_timeout_error": {
- "title": "YAML import failed because of timeout issues",
- "description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
+ "unknown": {
+ "message": "[%key:component::incomfort::config::error::unknown%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Intergas gateway options",
+ "data": {
+ "legacy_setpoint_status": "Legacy setpoint handling"
+ },
+ "data_description": {
+ "legacy_setpoint_status": "Some older gateway models with an older firmware versions might not update the thermostat setpoint and override settings correctly. Enable this option if you experience issues in updating the setpoint for your thermostat. It will use the actual setpoint of the thermostat instead of the override. As side effect is that it might take a few minutes before the setpoint is updated."
+ }
+ }
}
},
"entity": {
diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py
index e7620ac2a1a..0ab4a6a06b8 100644
--- a/homeassistant/components/incomfort/water_heater.py
+++ b/homeassistant/components/incomfort/water_heater.py
@@ -8,7 +8,7 @@ from typing import Any
from incomfortclient import Heater as InComfortHeater
from homeassistant.components.water_heater import WaterHeaterEntity
-from homeassistant.const import UnitOfTemperature
+from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -20,6 +20,8 @@ _LOGGER = logging.getLogger(__name__)
HEATER_ATTRS = ["display_code", "display_text", "is_burning"]
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -35,6 +37,7 @@ async def async_setup_entry(
class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
"""Representation of an InComfort/Intouch water_heater device."""
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_min_temp = 30.0
_attr_max_temp = 80.0
_attr_name = None
diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py
index b1c0cc53d61..95a94cf8fa0 100644
--- a/homeassistant/components/influxdb/__init__.py
+++ b/homeassistant/components/influxdb/__init__.py
@@ -40,8 +40,11 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import Event, HomeAssistant, State, callback
-from homeassistant.helpers import event as event_helper, state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ event as event_helper,
+ state as state_helper,
+)
from homeassistant.helpers.entity_values import EntityValues
from homeassistant.helpers.entityfilter import (
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py
index cab9d1e4c41..78cb7908eec 100644
--- a/homeassistant/components/influxdb/const.py
+++ b/homeassistant/components/influxdb/const.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
CONF_USERNAME,
CONF_VERIFY_SSL,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
CONF_DB_NAME = "database"
CONF_BUCKET = "bucket"
diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py
index cc601888f56..30319416a61 100644
--- a/homeassistant/components/influxdb/sensor.py
+++ b/homeassistant/components/influxdb/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady, TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py
index 0d4e404c9b5..09dd31a9cf6 100644
--- a/homeassistant/components/inkbird/config_flow.py
+++ b/homeassistant/components/inkbird/config_flow.py
@@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py
index 54457ab2fb7..a0a7514eaaf 100644
--- a/homeassistant/components/input_boolean/__init__.py
+++ b/homeassistant/components/input_boolean/__init__.py
@@ -19,8 +19,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py
index 69ff235948d..12bc98f7674 100644
--- a/homeassistant/components/input_button/__init__.py
+++ b/homeassistant/components/input_button/__init__.py
@@ -16,8 +16,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py
index 428ffccb7c1..60f882c2726 100644
--- a/homeassistant/components/input_datetime/__init__.py
+++ b/homeassistant/components/input_datetime/__init__.py
@@ -18,8 +18,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py
index d52bfedfe77..3352b55442a 100644
--- a/homeassistant/components/input_number/__init__.py
+++ b/homeassistant/components/input_number/__init__.py
@@ -19,8 +19,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py
index a117cf0a867..171998c02bc 100644
--- a/homeassistant/components/input_select/__init__.py
+++ b/homeassistant/components/input_select/__init__.py
@@ -27,8 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py
index 7d8f6663673..998bf35cd82 100644
--- a/homeassistant/components/input_text/__init__.py
+++ b/homeassistant/components/input_text/__init__.py
@@ -18,8 +18,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py
index 4d36f1d71e5..ac633e2a457 100644
--- a/homeassistant/components/insteon/api/properties.py
+++ b/homeassistant/components/insteon/api/properties.py
@@ -22,7 +22,7 @@ import voluptuous_serialize
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from ..const import (
DEVICE_ADDRESS,
diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py
index 143a9e2a5e2..54756397211 100644
--- a/homeassistant/components/insteon/config_flow.py
+++ b/homeassistant/components/insteon/config_flow.py
@@ -7,7 +7,7 @@ from typing import Any
from pyinsteon import async_connect
-from homeassistant.components import dhcp, usb
+from homeassistant.components import usb
from homeassistant.config_entries import (
DEFAULT_DISCOVERY_UNIQUE_ID,
ConfigFlow,
@@ -15,6 +15,8 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import CONF_HUB_VERSION, DOMAIN
from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema
@@ -129,9 +131,7 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN):
step_id=step_id, data_schema=data_schema, errors=errors
)
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB discovery."""
self._device_path = discovery_info.device
self._device_name = usb.human_readable_device_name(
@@ -162,7 +162,7 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a DHCP discovery."""
self.discovered_conf = {CONF_HOST: discovery_info.ip}
diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py
index 4cf8d49d170..70458dc5d6f 100644
--- a/homeassistant/components/insteon/schemas.py
+++ b/homeassistant/components/insteon/schemas.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
CONF_USERNAME,
ENTITY_MATCH_ALL,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONF_CAT,
diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json
index 4df997ac939..4a8aadb70db 100644
--- a/homeassistant/components/insteon/strings.json
+++ b/homeassistant/components/insteon/strings.json
@@ -164,7 +164,7 @@
},
"x10_all_units_off": {
"name": "X10 all units off",
- "description": "[%key:component::insteon::services::add_all_link::description%]",
+ "description": "Sends X10 'All units off' command.",
"fields": {
"housecode": {
"name": "Housecode",
@@ -174,7 +174,7 @@
},
"x10_all_lights_on": {
"name": "X10 all lights on",
- "description": "Sends X10 All Lights On command.",
+ "description": "Sends X10 'All lights on' command.",
"fields": {
"housecode": {
"name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]",
@@ -184,7 +184,7 @@
},
"x10_all_lights_off": {
"name": "X10 all lights off",
- "description": "Sends X10 All Lights Off command.",
+ "description": "Sends X10 'All lights off' command.",
"fields": {
"housecode": {
"name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]",
diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py
index 7609398673b..ce78f1a6fa3 100644
--- a/homeassistant/components/intellifire/__init__.py
+++ b/homeassistant/components/intellifire/__init__.py
@@ -149,7 +149,7 @@ async def _async_wait_for_initialization(
while (
fireplace.data.ipv4_address == "127.0.0.1" and fireplace.data.serial == "unset"
):
- LOGGER.debug(f"Waiting for fireplace to initialize [{fireplace.read_mode}]")
+ LOGGER.debug("Waiting for fireplace to initialize [%s]", fireplace.read_mode)
await asyncio.sleep(INIT_WAIT_TIME_SECONDS)
diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py
index a6b63f3b3e8..35c3bc09010 100644
--- a/homeassistant/components/intellifire/config_flow.py
+++ b/homeassistant/components/intellifire/config_flow.py
@@ -13,7 +13,6 @@ from intellifire4py.local_api import IntelliFireAPILocal
from intellifire4py.model import IntelliFireCommonFireplaceData
import voluptuous as vol
-from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
@@ -22,6 +21,7 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
API_MODE_LOCAL,
@@ -145,13 +145,13 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
LOGGER.debug(
- f"STEP: pick_cloud_device: {user_input} - DHCP_MODE[{self._dhcp_mode}"
+ "STEP: pick_cloud_device: %s - DHCP_MODE[%s]", user_input, self._dhcp_mode
)
if self._dhcp_mode or user_input is not None:
if self._dhcp_mode:
serial = self._dhcp_discovered_serial
- LOGGER.debug(f"DHCP Mode detected for serial [{serial}]")
+ LOGGER.debug("DHCP Mode detected for serial [%s]", serial)
if user_input is not None:
serial = user_input[CONF_SERIAL]
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index 71ef40ad369..a1451f8fcca 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -65,11 +65,11 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = [
- "async_register_timer_handler",
- "async_device_supports_timers",
- "TimerInfo",
- "TimerEventType",
"DOMAIN",
+ "TimerEventType",
+ "TimerInfo",
+ "async_device_supports_timers",
+ "async_register_timer_handler",
]
ONOFF_DEVICE_CLASSES = {
diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py
index 84b96492241..d641f8dc6b5 100644
--- a/homeassistant/components/intent/timers.py
+++ b/homeassistant/components/intent/timers.py
@@ -10,7 +10,7 @@ import logging
import time
from typing import Any
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
@@ -21,7 +21,7 @@ from homeassistant.helpers import (
device_registry as dr,
intent,
)
-from homeassistant.util import ulid
+from homeassistant.util import ulid as ulid_util
from .const import TIMER_DATA
@@ -261,7 +261,7 @@ class TimerManager:
if seconds is not None:
total_seconds += seconds
- timer_id = ulid.ulid_now()
+ timer_id = ulid_util.ulid_now()
created_at = time.monotonic_ns()
timer = TimerInfo(
id=timer_id,
diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py
index 1a1f58a6b80..a04a6ee6377 100644
--- a/homeassistant/components/intesishome/climate.py
+++ b/homeassistant/components/intesishome/climate.py
@@ -32,8 +32,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py
index b5bd0aea58f..cf70a97f52a 100644
--- a/homeassistant/components/ios/notify.py
+++ b/homeassistant/components/ios/notify.py
@@ -18,7 +18,7 @@ from homeassistant.components.notify import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import device_name_for_push_id, devices_with_push, enabled_push_ids
diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py
index a621f1fb27e..3fbe447f9fb 100644
--- a/homeassistant/components/iperf3/__init__.py
+++ b/homeassistant/components/iperf3/__init__.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfDataRate,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py
index a0ecf1f582e..9b0fbe29736 100644
--- a/homeassistant/components/ipma/config_flow.py
+++ b/homeassistant/components/ipma/config_flow.py
@@ -10,8 +10,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json
index 0d7df3fcf92..1abd7807213 100644
--- a/homeassistant/components/ipma/manifest.json
+++ b/homeassistant/components/ipma/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ipma",
"iot_class": "cloud_polling",
"loggers": ["geopy", "pyipma"],
- "requirements": ["pyipma==3.0.7"]
+ "requirements": ["pyipma==3.0.8"]
}
diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py
index 5f2cb98646b..2a921cdbb04 100644
--- a/homeassistant/components/ipma/sensor.py
+++ b/homeassistant/components/ipma/sensor.py
@@ -4,8 +4,9 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
import logging
+from typing import Any
from pyipma.api import IPMA_API
from pyipma.location import Location
@@ -28,23 +29,41 @@ _LOGGER = logging.getLogger(__name__)
class IPMASensorEntityDescription(SensorEntityDescription):
"""Describes a IPMA sensor entity."""
- value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]]
+ value_fn: Callable[
+ [Location, IPMA_API], Coroutine[Location, IPMA_API, tuple[Any, dict[str, Any]]]
+ ]
-async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None:
+async def async_retrieve_rcm(
+ location: Location, api: IPMA_API
+) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]:
"""Retrieve RCM."""
fire_risk: RCM = await location.fire_risk(api)
if fire_risk:
- return fire_risk.rcm
- return None
+ return fire_risk.rcm, {}
+ return None, {}
-async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None:
+async def async_retrieve_uvi(
+ location: Location, api: IPMA_API
+) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]:
"""Retrieve UV."""
uv_risk: UV = await location.uv_risk(api)
if uv_risk:
- return round(uv_risk.iUv)
- return None
+ return round(uv_risk.iUv), {}
+ return None, {}
+
+
+async def async_retrieve_warning(
+ location: Location, api: IPMA_API
+) -> tuple[Any, dict[str, str]]:
+ """Retrieve Warning."""
+ warnings = await location.warnings(api)
+ if len(warnings):
+ return warnings[0].awarenessLevelID, {
+ k: str(v) for k, v in asdict(warnings[0]).items()
+ }
+ return "green", {}
SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = (
@@ -58,6 +77,11 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = (
translation_key="uv_index",
value_fn=async_retrieve_uvi,
),
+ IPMASensorEntityDescription(
+ key="alert",
+ translation_key="weather_alert",
+ value_fn=async_retrieve_warning,
+ ),
)
@@ -94,6 +118,8 @@ class IPMASensor(SensorEntity, IPMADevice):
async def async_update(self) -> None:
"""Update sensors."""
async with asyncio.timeout(10):
- self._attr_native_value = await self.entity_description.value_fn(
+ state, attrs = await self.entity_description.value_fn(
self._location, self._api
)
+ self._attr_native_value = state
+ self._attr_extra_state_attributes = attrs
diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json
index ea5e5ff4759..ff9c23dd7ca 100644
--- a/homeassistant/components/ipma/strings.json
+++ b/homeassistant/components/ipma/strings.json
@@ -31,6 +31,15 @@
},
"uv_index": {
"name": "UV index"
+ },
+ "weather_alert": {
+ "name": "Weather Alert",
+ "state": {
+ "red": "Red",
+ "yellow": "Yellow",
+ "orange": "Orange",
+ "green": "Green"
+ }
}
}
}
diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py
index ecd4d1af9f6..4d0c43242e4 100644
--- a/homeassistant/components/ipp/config_flow.py
+++ b/homeassistant/components/ipp/config_flow.py
@@ -16,7 +16,6 @@ from pyipp import (
)
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
@@ -28,6 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_BASE_PATH, CONF_SERIAL, DOMAIN
@@ -103,7 +103,7 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
host = discovery_info.host
diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json
index 0236b72c89d..a738036b3ee 100644
--- a/homeassistant/components/iqvia/manifest.json
+++ b/homeassistant/components/iqvia/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyiqvia"],
- "requirements": ["numpy==2.2.0", "pyiqvia==2022.04.0"]
+ "requirements": ["numpy==2.2.2", "pyiqvia==2022.04.0"]
}
diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py
index 2765a14b7a3..cd0ccccaece 100644
--- a/homeassistant/components/irish_rail_transport/sensor.py
+++ b/homeassistant/components/irish_rail_transport/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py
index 9655f7bfcdd..6af6abb1436 100644
--- a/homeassistant/components/iron_os/__init__.py
+++ b/homeassistant/components/iron_os/__init__.py
@@ -27,9 +27,11 @@ from .coordinator import (
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
+ Platform.SWITCH,
Platform.UPDATE,
]
diff --git a/homeassistant/components/iron_os/button.py b/homeassistant/components/iron_os/button.py
new file mode 100644
index 00000000000..be16148a656
--- /dev/null
+++ b/homeassistant/components/iron_os/button.py
@@ -0,0 +1,85 @@
+"""Button platform for IronOS integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum
+
+from pynecil import CharSetting
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import IronOSConfigEntry
+from .coordinator import IronOSCoordinators
+from .entity import IronOSBaseEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class IronOSButtonEntityDescription(ButtonEntityDescription):
+ """Describes IronOS button entity."""
+
+ characteristic: CharSetting
+
+
+class IronOSButton(StrEnum):
+ """Button controls for IronOS device."""
+
+ SETTINGS_RESET = "settings_reset"
+ SETTINGS_SAVE = "settings_save"
+
+
+BUTTON_DESCRIPTIONS: tuple[IronOSButtonEntityDescription, ...] = (
+ IronOSButtonEntityDescription(
+ key=IronOSButton.SETTINGS_RESET,
+ translation_key=IronOSButton.SETTINGS_RESET,
+ characteristic=CharSetting.SETTINGS_RESET,
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSButtonEntityDescription(
+ key=IronOSButton.SETTINGS_SAVE,
+ translation_key=IronOSButton.SETTINGS_SAVE,
+ characteristic=CharSetting.SETTINGS_SAVE,
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: IronOSConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up button entities from a config entry."""
+ coordinators = entry.runtime_data
+
+ async_add_entities(
+ IronOSButtonEntity(coordinators, description)
+ for description in BUTTON_DESCRIPTIONS
+ )
+
+
+class IronOSButtonEntity(IronOSBaseEntity, ButtonEntity):
+ """Implementation of a IronOS button entity."""
+
+ entity_description: IronOSButtonEntityDescription
+
+ def __init__(
+ self,
+ coordinators: IronOSCoordinators,
+ entity_description: IronOSButtonEntityDescription,
+ ) -> None:
+ """Initialize the select entity."""
+ super().__init__(coordinators.live_data, entity_description)
+
+ self.settings = coordinators.settings
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+
+ await self.settings.write(self.entity_description.characteristic, True)
diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py
index e8ddef43bd7..080fee20762 100644
--- a/homeassistant/components/iron_os/coordinator.py
+++ b/homeassistant/components/iron_os/coordinator.py
@@ -4,9 +4,12 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
+from enum import Enum
import logging
+from typing import cast
from pynecil import (
+ CharSetting,
CommunicationError,
DeviceInfoResponse,
IronOSUpdate,
@@ -19,6 +22,7 @@ from pynecil import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -147,3 +151,25 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
_LOGGER.debug("Failed to fetch settings", exc_info=e)
return self.data or SettingsDataResponse()
+
+ async def write(
+ self,
+ characteristic: CharSetting,
+ value: bool | Enum | float,
+ ) -> None:
+ """Write value to the settings characteristic."""
+
+ try:
+ await self.device.write(characteristic, value)
+ except CommunicationError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="submit_setting_failed",
+ ) from e
+
+ # prevent switch bouncing while waiting for coordinator to finish refresh
+ self.data.update(
+ cast(SettingsDataResponse, {characteristic.name.lower(): value})
+ )
+ self.async_update_listeners()
+ await self.async_request_refresh()
diff --git a/homeassistant/components/iron_os/diagnostics.py b/homeassistant/components/iron_os/diagnostics.py
new file mode 100644
index 00000000000..e9545c24dec
--- /dev/null
+++ b/homeassistant/components/iron_os/diagnostics.py
@@ -0,0 +1,25 @@
+"""Diagnostics platform for IronOS integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant
+
+from . import IronOSConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: IronOSConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ return {
+ "config_entry_data": {
+ CONF_ADDRESS: config_entry.unique_id,
+ },
+ "device_info": config_entry.runtime_data.live_data.device_info,
+ "live_data": config_entry.runtime_data.live_data.data,
+ "settings_data": config_entry.runtime_data.settings.data,
+ }
diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json
index 0d26b027c3f..6410c561b9d 100644
--- a/homeassistant/components/iron_os/icons.json
+++ b/homeassistant/components/iron_os/icons.json
@@ -8,6 +8,14 @@
}
}
},
+ "button": {
+ "settings_save": {
+ "default": "mdi:content-save-cog"
+ },
+ "settings_reset": {
+ "default": "mdi:refresh"
+ }
+ },
"number": {
"setpoint_temperature": {
"default": "mdi:thermometer"
@@ -94,6 +102,9 @@
},
"logo_duration": {
"default": "mdi:clock-digital"
+ },
+ "usb_pd_mode": {
+ "default": "mdi:meter-electric-outline"
}
},
"sensor": {
@@ -149,6 +160,41 @@
"estimated_power": {
"default": "mdi:flash"
}
+ },
+ "switch": {
+ "animation_loop": {
+ "default": "mdi:play-box",
+ "state": {
+ "on": "mdi:animation-play"
+ }
+ },
+ "calibrate_cjc": {
+ "default": "mdi:tune-vertical"
+ },
+ "cooling_temp_blink": {
+ "default": "mdi:alarm-light-outline",
+ "state": {
+ "off": "mdi:alarm-light-off-outline"
+ }
+ },
+ "display_invert": {
+ "default": "mdi:invert-colors"
+ },
+ "invert_buttons": {
+ "default": "mdi:plus-minus-variant"
+ },
+ "idle_screen_details": {
+ "default": "mdi:card-bulleted-outline",
+ "state": {
+ "off": "mdi:card-bulleted-off-outline"
+ }
+ },
+ "solder_screen_details": {
+ "default": "mdi:card-bulleted-outline",
+ "state": {
+ "off": "mdi:card-bulleted-off-outline"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json
index 8556d1e3609..462e75c5b6e 100644
--- a/homeassistant/components/iron_os/manifest.json
+++ b/homeassistant/components/iron_os/manifest.json
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/iron_os",
"iot_class": "local_polling",
"loggers": ["pynecil"],
- "requirements": ["pynecil==2.1.0"]
+ "requirements": ["pynecil==4.0.1"]
}
diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py
index e50b227bbef..518c11372c4 100644
--- a/homeassistant/components/iron_os/number.py
+++ b/homeassistant/components/iron_os/number.py
@@ -6,12 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
-from pynecil import (
- CharSetting,
- CommunicationError,
- LiveDataResponse,
- SettingsDataResponse,
-)
+from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
@@ -28,11 +23,10 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import IronOSConfigEntry
-from .const import DOMAIN, MAX_TEMP, MIN_TEMP
+from .const import MAX_TEMP, MIN_TEMP
from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
@@ -363,16 +357,8 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
"""Update the current value."""
if raw_value_fn := self.entity_description.raw_value_fn:
value = raw_value_fn(value)
- try:
- await self.coordinator.device.write(
- self.entity_description.characteristic, value
- )
- except CommunicationError as e:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="submit_setting_failed",
- ) from e
- await self.settings.async_request_refresh()
+
+ await self.settings.write(self.entity_description.characteristic, value)
@property
def native_value(self) -> float | int | None:
diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml
index 922702b8260..c80b8b5adfe 100644
--- a/homeassistant/components/iron_os/quality_scale.yaml
+++ b/homeassistant/components/iron_os/quality_scale.yaml
@@ -26,16 +26,12 @@ rules:
unique-config-entry: done
# Silver
- action-exceptions:
- status: exempt
- comment: Integration does not have actions
+ action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow
- docs-installation-parameters:
- status: todo
- comment: Needs bluetooth address as parameter
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -47,7 +43,7 @@ rules:
# Gold
devices: done
- diagnostics: todo
+ diagnostics: done
discovery-update-info:
status: exempt
comment: Device is not connected to an ip network. Other information from discovery is immutable and does not require updating.
diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py
index 10d8a6fcef5..e9c7f81c208 100644
--- a/homeassistant/components/iron_os/select.py
+++ b/homeassistant/components/iron_os/select.py
@@ -5,30 +5,27 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, StrEnum
-from typing import Any
from pynecil import (
AnimationSpeed,
AutostartMode,
BatteryType,
CharSetting,
- CommunicationError,
LockingMode,
LogoDuration,
ScreenOrientationMode,
ScrollSpeed,
SettingsDataResponse,
TempUnit,
+ USBPDMode,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import IronOSConfigEntry
-from .const import DOMAIN
from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
@@ -41,7 +38,7 @@ class IronOSSelectEntityDescription(SelectEntityDescription):
value_fn: Callable[[SettingsDataResponse], str | None]
characteristic: CharSetting
- raw_value_fn: Callable[[str], Any] | None = None
+ raw_value_fn: Callable[[str], Enum]
class PinecilSelect(StrEnum):
@@ -55,6 +52,7 @@ class PinecilSelect(StrEnum):
DESC_SCROLL_SPEED = "desc_scroll_speed"
LOCKING_MODE = "locking_mode"
LOGO_DURATION = "logo_duration"
+ USB_PD_MODE = "usb_pd_mode"
def enum_to_str(enum: Enum | None) -> str | None:
@@ -140,6 +138,16 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.USB_PD_MODE,
+ translation_key=PinecilSelect.USB_PD_MODE,
+ characteristic=CharSetting.USB_PD_MODE,
+ value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")),
+ raw_value_fn=lambda value: USBPDMode[value.upper()],
+ options=["off", "on"],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
)
@@ -181,18 +189,10 @@ class IronOSSelectEntity(IronOSBaseEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
- if raw_value_fn := self.entity_description.raw_value_fn:
- value = raw_value_fn(option)
- try:
- await self.coordinator.device.write(
- self.entity_description.characteristic, value
- )
- except CommunicationError as e:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="submit_setting_failed",
- ) from e
- await self.settings.async_request_refresh()
+ await self.settings.write(
+ self.entity_description.characteristic,
+ self.entity_description.raw_value_fn(option),
+ )
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json
index 967b966e44e..60168699427 100644
--- a/homeassistant/components/iron_os/strings.json
+++ b/homeassistant/components/iron_os/strings.json
@@ -29,6 +29,14 @@
"name": "Soldering tip"
}
},
+ "button": {
+ "settings_save": {
+ "name": "Save settings"
+ },
+ "settings_reset": {
+ "name": "Restore default settings"
+ }
+ },
"number": {
"setpoint_temperature": {
"name": "Setpoint temperature"
@@ -158,6 +166,13 @@
"seconds_5": "5 second",
"loop": "Loop"
}
+ },
+ "usb_pd_mode": {
+ "name": "Power Delivery 3.1 EPR",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
}
},
"sensor": {
@@ -214,6 +229,29 @@
"estimated_power": {
"name": "Estimated power"
}
+ },
+ "switch": {
+ "animation_loop": {
+ "name": "Animation loop"
+ },
+ "cooling_temp_blink": {
+ "name": "Cool down screen flashing"
+ },
+ "idle_screen_details": {
+ "name": "Detailed idle screen"
+ },
+ "solder_screen_details": {
+ "name": "Detailed solder screen"
+ },
+ "invert_buttons": {
+ "name": "Swap +/- buttons"
+ },
+ "display_invert": {
+ "name": "Invert screen"
+ },
+ "calibrate_cjc": {
+ "name": "Calibrate CJC"
+ }
}
},
"exceptions": {
diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py
new file mode 100644
index 00000000000..d88e8cfdcb5
--- /dev/null
+++ b/homeassistant/components/iron_os/switch.py
@@ -0,0 +1,154 @@
+"""Switch platform for IronOS integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import StrEnum
+from typing import Any
+
+from pynecil import CharSetting, SettingsDataResponse
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import IronOSConfigEntry
+from .coordinator import IronOSCoordinators
+from .entity import IronOSBaseEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class IronOSSwitchEntityDescription(SwitchEntityDescription):
+ """Describes IronOS switch entity."""
+
+ is_on_fn: Callable[[SettingsDataResponse], bool | None]
+ characteristic: CharSetting
+
+
+class IronOSSwitch(StrEnum):
+ """Switch controls for IronOS device."""
+
+ ANIMATION_LOOP = "animation_loop"
+ COOLING_TEMP_BLINK = "cooling_temp_blink"
+ IDLE_SCREEN_DETAILS = "idle_screen_details"
+ SOLDER_SCREEN_DETAILS = "solder_screen_details"
+ INVERT_BUTTONS = "invert_buttons"
+ DISPLAY_INVERT = "display_invert"
+ CALIBRATE_CJC = "calibrate_cjc"
+
+
+SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = (
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.ANIMATION_LOOP,
+ translation_key=IronOSSwitch.ANIMATION_LOOP,
+ characteristic=CharSetting.ANIMATION_LOOP,
+ is_on_fn=lambda x: x.get("animation_loop"),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.COOLING_TEMP_BLINK,
+ translation_key=IronOSSwitch.COOLING_TEMP_BLINK,
+ characteristic=CharSetting.COOLING_TEMP_BLINK,
+ is_on_fn=lambda x: x.get("cooling_temp_blink"),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.IDLE_SCREEN_DETAILS,
+ translation_key=IronOSSwitch.IDLE_SCREEN_DETAILS,
+ characteristic=CharSetting.IDLE_SCREEN_DETAILS,
+ is_on_fn=lambda x: x.get("idle_screen_details"),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.SOLDER_SCREEN_DETAILS,
+ translation_key=IronOSSwitch.SOLDER_SCREEN_DETAILS,
+ characteristic=CharSetting.SOLDER_SCREEN_DETAILS,
+ is_on_fn=lambda x: x.get("solder_screen_details"),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.INVERT_BUTTONS,
+ translation_key=IronOSSwitch.INVERT_BUTTONS,
+ characteristic=CharSetting.INVERT_BUTTONS,
+ is_on_fn=lambda x: x.get("invert_buttons"),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.DISPLAY_INVERT,
+ translation_key=IronOSSwitch.DISPLAY_INVERT,
+ characteristic=CharSetting.DISPLAY_INVERT,
+ is_on_fn=lambda x: x.get("display_invert"),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSwitchEntityDescription(
+ key=IronOSSwitch.CALIBRATE_CJC,
+ translation_key=IronOSSwitch.CALIBRATE_CJC,
+ characteristic=CharSetting.CALIBRATE_CJC,
+ is_on_fn=lambda x: x.get("calibrate_cjc"),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: IronOSConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up switches from a config entry."""
+
+ coordinators = entry.runtime_data
+
+ async_add_entities(
+ IronOSSwitchEntity(coordinators, description)
+ for description in SWITCH_DESCRIPTIONS
+ )
+
+
+class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity):
+ """Representation of a IronOS Switch."""
+
+ entity_description: IronOSSwitchEntityDescription
+
+ def __init__(
+ self,
+ coordinators: IronOSCoordinators,
+ entity_description: IronOSSwitchEntityDescription,
+ ) -> None:
+ """Initialize the switch entity."""
+ super().__init__(coordinators.live_data, entity_description)
+
+ self.settings = coordinators.settings
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return the state of the device."""
+ return self.entity_description.is_on_fn(
+ self.settings.data,
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self.settings.write(self.entity_description.characteristic, True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self.settings.write(self.entity_description.characteristic, False)
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.settings.async_add_listener(
+ self._handle_coordinator_update, self.entity_description.characteristic
+ )
+ )
+ await self.settings.async_request_refresh()
diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json
index 94f20b4d93c..caa176ab6b6 100644
--- a/homeassistant/components/iskra/manifest.json
+++ b/homeassistant/components/iskra/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyiskra"],
- "requirements": ["pyiskra==0.1.14"]
+ "requirements": ["pyiskra==0.1.15"]
}
diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py
index 7005bee3585..35903afa393 100644
--- a/homeassistant/components/islamic_prayer_times/coordinator.py
+++ b/homeassistant/components/islamic_prayer_times/coordinator.py
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
CONF_CALC_METHOD,
diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py
index d707f8c5ea6..b022e3fd790 100644
--- a/homeassistant/components/israel_rail/coordinator.py
+++ b/homeassistant/components/israel_rail/coordinator.py
@@ -13,7 +13,7 @@ from israelrailapi.train_station import station_name_to_id
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 homeassistant.util import dt as dt_util
from .const import DEFAULT_SCAN_INTERVAL, DEPARTURES_COUNT, DOMAIN
diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py
index c11c43070df..1a3b2109d0c 100644
--- a/homeassistant/components/ista_ecotrend/config_flow.py
+++ b/homeassistant/components/ista_ecotrend/config_flow.py
@@ -66,7 +66,7 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
else:
if TYPE_CHECKING:
assert info
- title = f"{info["firstName"]} {info["lastName"]}".strip()
+ title = f"{info['firstName']} {info['lastName']}".strip()
await self.async_set_unique_id(info["activeConsumptionUnit"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py
index eb06fabe373..e96ac103741 100644
--- a/homeassistant/components/ista_ecotrend/sensor.py
+++ b/homeassistant/components/ista_ecotrend/sensor.py
@@ -184,12 +184,12 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity):
self.consumption_unit = consumption_unit
self.entity_description = entity_description
self._attr_unique_id = f"{consumption_unit}_{entity_description.key}"
+ address = coordinator.details[consumption_unit]["address"]
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer="ista SE",
model="ista EcoTrend",
- name=f"{coordinator.details[consumption_unit]["address"]["street"]} "
- f"{coordinator.details[consumption_unit]["address"]["houseNumber"]}".strip(),
+ name=f"{address['street']} {address['houseNumber']}".strip(),
configuration_url="https://ecotrend.ista.de/",
identifiers={(DOMAIN, consumption_unit)},
)
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index d2862054971..738c7e2d5ad 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -21,8 +21,11 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import aiohttp_client, config_validation as cv
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import (
+ aiohttp_client,
+ config_validation as cv,
+ device_registry as dr,
+)
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from .const import (
diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py
index 3575fa99a55..b44096e2ccd 100644
--- a/homeassistant/components/isy994/config_flow.py
+++ b/homeassistant/components/isy994/config_flow.py
@@ -14,7 +14,6 @@ from pyisy.configuration import Configuration
from pyisy.connection import Connection
import voluptuous as vol
-from homeassistant.components import dhcp, ssdp
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigEntry,
@@ -27,6 +26,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .const import (
CONF_IGNORE_STRING,
@@ -209,7 +214,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
raise AbortFlow("already_configured")
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered ISY/IoX device via dhcp."""
friendly_name = discovery_info.hostname
@@ -232,14 +237,14 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered ISY/IoX Device."""
- friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
+ friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]
url = discovery_info.ssdp_location
assert isinstance(url, str)
parsed_url = urlparse(url)
- mac = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
+ mac = discovery_info.upnp[ATTR_UPNP_UDN]
mac = mac.removeprefix(UDN_UUID_PREFIX)
url = url.removesuffix(ISY_URL_POSTFIX)
diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py
index 41e5899504d..8befcf024d1 100644
--- a/homeassistant/components/isy994/select.py
+++ b/homeassistant/components/isy994/select.py
@@ -45,7 +45,7 @@ from .models import IsyData
def time_string(i: int) -> str:
"""Return a formatted ramp rate time string."""
if i >= 60:
- return f"{(float(i)/60):.1f} {UnitOfTime.MINUTES}"
+ return f"{(float(i) / 60):.1f} {UnitOfTime.MINUTES}"
return f"{i} {UnitOfTime.SECONDS}"
diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py
index 1cd46446ed6..6546aec6efa 100644
--- a/homeassistant/components/isy994/services.py
+++ b/homeassistant/components/isy994/services.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.service import entity_service_call
diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json
index f0e55881652..86a1f14ff91 100644
--- a/homeassistant/components/isy994/strings.json
+++ b/homeassistant/components/isy994/strings.json
@@ -37,7 +37,7 @@
"step": {
"init": {
"title": "ISY Options",
- "description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.",
+ "description": "Set the options for the ISY integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.",
"data": {
"sensor_string": "Node Sensor String",
"ignore_string": "Ignore String",
@@ -62,7 +62,7 @@
"fields": {
"command": {
"name": "Command",
- "description": "The ISY REST Command to be sent to the device."
+ "description": "The ISY REST command to be sent to the device."
},
"value": {
"name": "Value",
@@ -74,13 +74,13 @@
},
"unit_of_measurement": {
"name": "Unit of measurement",
- "description": "The ISY Unit of Measurement (UOM) to send with the command, if required."
+ "description": "The ISY unit of measurement (UOM) to send with the command, if required."
}
}
},
"send_node_command": {
"name": "Send node command",
- "description": "Sends a command to an ISY Device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.",
+ "description": "Sends a command to an ISY device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.",
"fields": {
"command": {
"name": "Command",
@@ -90,7 +90,7 @@
},
"get_zwave_parameter": {
"name": "Get Z-Wave Parameter",
- "description": "Requests a Z-Wave Device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
+ "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"fields": {
"parameter": {
"name": "Parameter",
@@ -99,8 +99,8 @@
}
},
"set_zwave_parameter": {
- "name": "Set Z-Wave Parameter",
- "description": "Updates a Z-Wave Device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
+ "name": "Set Z-Wave parameter",
+ "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"fields": {
"parameter": {
"name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]",
@@ -117,8 +117,8 @@
}
},
"set_zwave_lock_user_code": {
- "name": "Set Z-Wave Lock User Code",
- "description": "Sets a Z-Wave Lock User Code via the ISY.",
+ "name": "Set Z-Wave lock user code",
+ "description": "Sets a user code for a Z-Wave lock via the ISY.",
"fields": {
"user_num": {
"name": "User Number",
@@ -131,8 +131,8 @@
}
},
"delete_zwave_lock_user_code": {
- "name": "Delete Z-Wave Lock User Code",
- "description": "Delete a Z-Wave Lock User Code via the ISY.",
+ "name": "Delete Z-Wave lock user code",
+ "description": "Deletes a user code for a Z-Wave lock via the ISY.",
"fields": {
"user_num": {
"name": "[%key:component::isy994::services::set_zwave_lock_user_code::fields::user_num::name%]",
@@ -141,8 +141,8 @@
}
},
"rename_node": {
- "name": "Rename Node on ISY",
- "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.",
+ "name": "Rename node on ISY",
+ "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant entity name or entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.",
"fields": {
"name": {
"name": "New Name",
diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py
index ed1a5abca8b..ca5c5ea46a9 100644
--- a/homeassistant/components/isy994/util.py
+++ b/homeassistant/components/isy994/util.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from .const import _LOGGER, DOMAIN
diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py
index 986dbfb8b95..235d290cccb 100644
--- a/homeassistant/components/itach/remote.py
+++ b/homeassistant/components/itach/remote.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
DEVICE_DEFAULT_NAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py
index 0f241041c0d..92e3aefe975 100644
--- a/homeassistant/components/itunes/media_player.py
+++ b/homeassistant/components/itunes/media_player.py
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py
index c00f2d1f83f..1fd9a03e05f 100644
--- a/homeassistant/components/izone/__init__.py
+++ b/homeassistant/components/izone/__init__.py
@@ -6,7 +6,7 @@ from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DATA_CONFIG, IZONE
diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json
index 19358cff17c..810b9ea45a9 100644
--- a/homeassistant/components/jellyfin/manifest.json
+++ b/homeassistant/components/jellyfin/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["jellyfin_apiclient_python"],
- "requirements": ["jellyfin-apiclient-python==1.9.2"],
+ "requirements": ["jellyfin-apiclient-python==1.10.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py
index 9fd1371f8a8..85519bf37b0 100644
--- a/homeassistant/components/jewish_calendar/binary_sensor.py
+++ b/homeassistant/components/jewish_calendar/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import event
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py
index d3e70eb411c..5e02435ed06 100644
--- a/homeassistant/components/jewish_calendar/sensor.py
+++ b/homeassistant/components/jewish_calendar/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sun import get_astral_event_date
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
diff --git a/homeassistant/components/joaoapps_join/__init__.py b/homeassistant/components/joaoapps_join/__init__.py
index f537866054f..89b5748a714 100644
--- a/homeassistant/components/joaoapps_join/__init__.py
+++ b/homeassistant/components/joaoapps_join/__init__.py
@@ -15,7 +15,7 @@ import voluptuous as vol
from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_NAME
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py
index 7fab894b0e4..a3432b96b13 100644
--- a/homeassistant/components/joaoapps_join/notify.py
+++ b/homeassistant/components/joaoapps_join/notify.py
@@ -16,7 +16,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py
index e4a562dc00b..031709db9f2 100644
--- a/homeassistant/components/kaleidescape/config_flow.py
+++ b/homeassistant/components/kaleidescape/config_flow.py
@@ -7,9 +7,9 @@ from urllib.parse import urlparse
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo
from . import KaleidescapeDeviceInfo, UnsupportedError, validate_host
from .const import DEFAULT_HOST, DOMAIN, NAME as KALEIDESCAPE_NAME
@@ -61,11 +61,11 @@ class KaleidescapeConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle discovered device."""
host = cast(str, urlparse(discovery_info.ssdp_location).hostname)
- serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
+ serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL]
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py
index cd91b7660c8..51bddebeb77 100644
--- a/homeassistant/components/kankun/switch.py
+++ b/homeassistant/components/kankun/switch.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/keba/__init__.py b/homeassistant/components/keba/__init__.py
index 34eb7c99166..2c372cf1a25 100644
--- a/homeassistant/components/keba/__init__.py
+++ b/homeassistant/components/keba/__init__.py
@@ -8,8 +8,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py
index d11fedac385..3dc4c8b1b77 100644
--- a/homeassistant/components/keenetic_ndms2/config_flow.py
+++ b/homeassistant/components/keenetic_ndms2/config_flow.py
@@ -8,7 +8,6 @@ from urllib.parse import urlparse
from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -24,6 +23,11 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -105,23 +109,23 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered device."""
- friendly_name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "")
+ friendly_name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "")
# Filter out items not having "keenetic" in their name
if "keenetic" not in friendly_name.lower():
return self.async_abort(reason="not_keenetic_ndms2")
# Filters out items having no/empty UDN
- if not discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
+ if not discovery_info.upnp.get(ATTR_UPNP_UDN):
return self.async_abort(reason="no_udn")
# We can cast the hostname to str because the ssdp_location is not bytes and
# not a relative url
host = cast(str, urlparse(discovery_info.ssdp_location).hostname)
- await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN])
+ await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._async_abort_entries_match({CONF_HOST: host})
diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py
index efd2a88b1f8..0f5166e16dd 100644
--- a/homeassistant/components/keenetic_ndms2/device_tracker.py
+++ b/homeassistant/components/keenetic_ndms2/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, ROUTER
from .router import KeeneticRouter
diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py
index 5a4f32a05cd..8c3079b910d 100644
--- a/homeassistant/components/keenetic_ndms2/router.py
+++ b/homeassistant/components/keenetic_ndms2/router.py
@@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
CONF_CONSIDER_HOME,
diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json
index 1bbce2ff35d..db331fe6874 100644
--- a/homeassistant/components/kef/manifest.json
+++ b/homeassistant/components/kef/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["aiokef", "tenacity"],
"quality_scale": "legacy",
- "requirements": ["aiokef==0.2.16", "getmac==0.9.4"]
+ "requirements": ["aiokef==0.2.16", "getmac==0.9.5"]
}
diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json
index c8aa644333a..56fdeaa6704 100644
--- a/homeassistant/components/kef/strings.json
+++ b/homeassistant/components/kef/strings.json
@@ -39,7 +39,7 @@
"description": "Sets the \"Desk mode\" slider of the speaker in dB.",
"fields": {
"db_value": {
- "name": "DB value",
+ "name": "dB value",
"description": "Value of the slider."
}
}
@@ -75,8 +75,8 @@
}
},
"set_low_hz": {
- "name": "Sets low Hertz",
- "description": "Set the \"Sub out low-pass frequency\" slider of the speaker in Hz.",
+ "name": "Set low Hertz",
+ "description": "Sets the \"Sub out low-pass frequency\" slider of the speaker in Hz.",
"fields": {
"hz_value": {
"name": "[%key:component::kef::services::set_high_hz::fields::hz_value::name%]",
@@ -85,8 +85,8 @@
}
},
"set_sub_db": {
- "name": "Sets subwoofer dB",
- "description": "Set the \"Sub gain\" slider of the speaker in dB.",
+ "name": "Set subwoofer dB",
+ "description": "Sets the \"Sub gain\" slider of the speaker in dB.",
"fields": {
"db_value": {
"name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]",
diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py
index 5831a770466..979aeb73e45 100644
--- a/homeassistant/components/keyboard_remote/__init__.py
+++ b/homeassistant/components/keyboard_remote/__init__.py
@@ -14,7 +14,7 @@ import voluptuous as vol
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json
index b405f36bb23..f543ae72972 100644
--- a/homeassistant/components/keyboard_remote/manifest.json
+++ b/homeassistant/components/keyboard_remote/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["aionotify", "evdev"],
"quality_scale": "legacy",
- "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"]
+ "requirements": ["evdev==1.6.1", "asyncinotify==4.2.0"]
}
diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py
index 217ce3cc923..821fbc410f7 100644
--- a/homeassistant/components/keymitt_ble/config_flow.py
+++ b/homeassistant/components/keymitt_ble/config_flow.py
@@ -34,7 +34,7 @@ def short_address(address: str) -> str:
def name_from_discovery(discovery: MicroBotAdvertisement) -> str:
"""Get the name from a discovery."""
- return f'{discovery.data["local_name"]} {short_address(discovery.address)}'
+ return f"{discovery.data['local_name']} {short_address(discovery.address)}"
class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN):
diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py
index 52618a125b6..092fdf8398c 100644
--- a/homeassistant/components/kira/__init__.py
+++ b/homeassistant/components/kira/__init__.py
@@ -21,8 +21,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
DOMAIN = "kira"
diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py
index 88d0c868636..09a72fc529c 100644
--- a/homeassistant/components/kitchen_sink/__init__.py
+++ b/homeassistant/components/kitchen_sink/__init__.py
@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py
index c4a045aeefc..44ac0456105 100644
--- a/homeassistant/components/kitchen_sink/backup.py
+++ b/homeassistant/components/kitchen_sink/backup.py
@@ -51,7 +51,7 @@ class KitchenSinkBackupAgent(BackupAgent):
def __init__(self, name: str) -> None:
"""Initialize the kitchen sink backup sync agent."""
super().__init__()
- self.name = name
+ self.name = self.unique_id = name
self._uploads = [
AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json
index b8dcfdd8e69..c03f909e617 100644
--- a/homeassistant/components/kitchen_sink/strings.json
+++ b/homeassistant/components/kitchen_sink/strings.json
@@ -80,8 +80,8 @@
},
"services": {
"test_service_1": {
- "name": "Test service 1",
- "description": "Fake service for testing",
+ "name": "Test action 1",
+ "description": "Fake action for testing",
"fields": {
"field_1": {
"name": "Field 1",
diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py
index 8a12cb4bdb9..e94e823c692 100644
--- a/homeassistant/components/kitchen_sink/weather.py
+++ b/homeassistant/components/kitchen_sink/weather.py
@@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
CONDITION_CLASSES: dict[str, list[str]] = {
ATTR_CONDITION_CLOUDY: [],
diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py
index 887747d4ca4..d378fcbcbed 100644
--- a/homeassistant/components/kiwi/lock.py
+++ b/homeassistant/components/kiwi/lock.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index edb9cc62008..fa3439b02f4 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -10,6 +10,7 @@ from typing import Final
import voluptuous as vol
from xknx import XKNX
from xknx.core import XknxConnectionState
+from xknx.core.state_updater import StateTrackerType, TrackerOptions
from xknx.core.telegram_queue import TelegramQueue
from xknx.dpt import DPTBase
from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException
@@ -91,7 +92,7 @@ from .schema import (
WeatherSchema,
)
from .services import register_knx_services
-from .storage.config_store import KNXConfigStore
+from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
from .websocket import register_panel
@@ -226,6 +227,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
if knxkeys_filename is not None:
with contextlib.suppress(FileNotFoundError):
(storage_dir / knxkeys_filename).unlink()
+ with contextlib.suppress(FileNotFoundError):
+ (storage_dir / CONFIG_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / PROJECT_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError):
@@ -271,11 +274,18 @@ class KNXModule:
self.project = KNXProject(hass=hass, entry=entry)
self.config_store = KNXConfigStore(hass=hass, config_entry=entry)
+ default_state_updater = (
+ TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60)
+ if self.entry.data[CONF_KNX_STATE_UPDATER]
+ else TrackerOptions(
+ tracker_type=StateTrackerType.INIT, update_interval_min=60
+ )
+ )
self.xknx = XKNX(
address_format=self.project.get_address_format(),
connection_config=self.connection_config(),
rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT],
- state_updater=self.entry.data[CONF_KNX_STATE_UPDATER],
+ state_updater=default_state_updater,
)
self.xknx.connection_manager.register_connection_state_changed_cb(
self.connection_state_changed_cb
diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py
index 96438df96d7..c629860351c 100644
--- a/homeassistant/components/knx/binary_sensor.py
+++ b/homeassistant/components/knx/binary_sensor.py
@@ -18,14 +18,28 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-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
from . import KNXModule
-from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY
-from .entity import KnxYamlEntity
-from .schema import BinarySensorSchema
+from .const import (
+ ATTR_COUNTER,
+ ATTR_SOURCE,
+ CONF_CONTEXT_TIMEOUT,
+ CONF_IGNORE_INTERNAL_STATE,
+ CONF_INVERT,
+ CONF_RESET_AFTER,
+ CONF_STATE_ADDRESS,
+ CONF_SYNC_STATE,
+ DOMAIN,
+ KNX_MODULE_KEY,
+)
+from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
+from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE
async def async_setup_entry(
@@ -35,40 +49,38 @@ async def async_setup_entry(
) -> None:
"""Set up the KNX binary sensor platform."""
knx_module = hass.data[KNX_MODULE_KEY]
- config: list[ConfigType] = knx_module.config_yaml[Platform.BINARY_SENSOR]
-
- async_add_entities(
- KNXBinarySensor(knx_module, entity_config) for entity_config in config
+ platform = async_get_current_platform()
+ knx_module.config_store.add_platform(
+ platform=Platform.BINARY_SENSOR,
+ controller=KnxUiEntityPlatformController(
+ knx_module=knx_module,
+ entity_platform=platform,
+ entity_class=KnxUiBinarySensor,
+ ),
)
+ entities: list[KnxYamlEntity | KnxUiEntity] = []
+ if yaml_platform_config := knx_module.config_yaml.get(Platform.BINARY_SENSOR):
+ entities.extend(
+ KnxYamlBinarySensor(knx_module, entity_config)
+ for entity_config in yaml_platform_config
+ )
+ if ui_config := knx_module.config_store.data["entities"].get(
+ Platform.BINARY_SENSOR
+ ):
+ entities.extend(
+ KnxUiBinarySensor(knx_module, unique_id, config)
+ for unique_id, config in ui_config.items()
+ )
+ if entities:
+ async_add_entities(entities)
-class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity):
+
+class _KnxBinarySensor(BinarySensorEntity, RestoreEntity):
"""Representation of a KNX binary sensor."""
_device: XknxBinarySensor
- def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
- """Initialize of KNX binary sensor."""
- super().__init__(
- knx_module=knx_module,
- device=XknxBinarySensor(
- xknx=knx_module.xknx,
- name=config[CONF_NAME],
- group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS],
- invert=config[BinarySensorSchema.CONF_INVERT],
- sync_state=config[BinarySensorSchema.CONF_SYNC_STATE],
- ignore_internal_state=config[
- BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE
- ],
- 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)
- self._attr_force_update = self._device.ignore_internal_state
- self._attr_unique_id = str(self._device.remote_value.group_address_state)
-
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
@@ -92,3 +104,59 @@ class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity):
if self._device.last_telegram is not None:
attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address)
return attr
+
+
+class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity):
+ """Representation of a KNX binary sensor configured from YAML."""
+
+ _device: XknxBinarySensor
+
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
+ """Initialize of KNX binary sensor."""
+ super().__init__(
+ knx_module=knx_module,
+ device=XknxBinarySensor(
+ xknx=knx_module.xknx,
+ name=config[CONF_NAME],
+ group_address_state=config[CONF_STATE_ADDRESS],
+ invert=config[CONF_INVERT],
+ sync_state=config[CONF_SYNC_STATE],
+ ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
+ context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
+ reset_after=config.get(CONF_RESET_AFTER),
+ ),
+ )
+ self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
+ self._attr_device_class = config.get(CONF_DEVICE_CLASS)
+ self._attr_force_update = self._device.ignore_internal_state
+ self._attr_unique_id = str(self._device.remote_value.group_address_state)
+
+
+class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity):
+ """Representation of a KNX binary sensor configured from UI."""
+
+ _device: XknxBinarySensor
+
+ def __init__(
+ self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
+ ) -> None:
+ """Initialize KNX binary sensor."""
+ super().__init__(
+ knx_module=knx_module,
+ unique_id=unique_id,
+ entity_config=config[CONF_ENTITY],
+ )
+ self._device = XknxBinarySensor(
+ xknx=knx_module.xknx,
+ name=config[CONF_ENTITY][CONF_NAME],
+ group_address_state=[
+ config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE],
+ *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE],
+ ],
+ sync_state=config[DOMAIN][CONF_SYNC_STATE],
+ invert=config[DOMAIN].get(CONF_INVERT, False),
+ ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False),
+ context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT),
+ reset_after=config[DOMAIN].get(CONF_RESET_AFTER),
+ )
+ self._attr_force_update = self._device.ignore_internal_state
diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py
index af58dd6ef4d..e3bb63581e7 100644
--- a/homeassistant/components/knx/climate.py
+++ b/homeassistant/components/knx/climate.py
@@ -19,6 +19,8 @@ from homeassistant.components.climate import (
FAN_LOW,
FAN_MEDIUM,
FAN_ON,
+ SWING_OFF,
+ SWING_ON,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -136,6 +138,14 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS
),
fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE],
+ group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS),
+ group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS),
+ group_address_horizontal_swing=config.get(
+ ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS
+ ),
+ group_address_horizontal_swing_state=config.get(
+ ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS
+ ),
group_address_humidity_state=config.get(
ClimateSchema.CONF_HUMIDITY_STATE_ADDRESS
),
@@ -207,6 +217,13 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
self._attr_fan_modes = [self.fan_zero_mode] + [
f"{percentage}%" for percentage in self._fan_modes_percentages[1:]
]
+ if self._device.swing.initialized:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
+ self._attr_swing_modes = [SWING_ON, SWING_OFF]
+
+ if self._device.horizontal_swing.initialized:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
+ self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF]
self._attr_target_temperature_step = self._device.temperature_step
self._attr_unique_id = (
@@ -399,6 +416,28 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
await self._device.set_fan_speed(self._fan_modes_percentages[fan_mode_index])
+ async def async_set_swing_mode(self, swing_mode: str) -> None:
+ """Set the swing setting."""
+ await self._device.set_swing(swing_mode == SWING_ON)
+
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set the horizontal swing setting."""
+ await self._device.set_horizontal_swing(swing_horizontal_mode == SWING_ON)
+
+ @property
+ def swing_mode(self) -> str | None:
+ """Return the swing setting."""
+ if self._device.swing.value is not None:
+ return SWING_ON if self._device.swing.value else SWING_OFF
+ return None
+
+ @property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the horizontal swing setting."""
+ if self._device.horizontal_swing.value is not None:
+ return SWING_ON if self._device.horizontal_swing.value else SWING_OFF
+ return None
+
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
@@ -427,7 +466,7 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
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:
+ 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(
@@ -435,4 +474,4 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
)
if hvac_mode is not HVACMode.OFF:
self._last_hvac_mode = hvac_mode
- super().after_update_callback(_device)
+ super().after_update_callback(device)
diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py
index a946ded0359..b403018dae3 100644
--- a/homeassistant/components/knx/const.py
+++ b/homeassistant/components/knx/const.py
@@ -67,6 +67,8 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password"
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication"
+CONF_CONTEXT_TIMEOUT: Final = "context_timeout"
+CONF_IGNORE_INTERNAL_STATE: Final = "ignore_internal_state"
CONF_PAYLOAD_LENGTH: Final = "payload_length"
CONF_RESET_AFTER: Final = "reset_after"
CONF_RESPOND_TO_READ: Final = "respond_to_read"
@@ -114,7 +116,7 @@ class KNXConfigEntryData(TypedDict, total=False):
backbone_key: str | None # not required
sync_latency_tolerance: int | None # not required
# OptionsFlow only
- state_updater: bool
+ state_updater: bool # default state updater: True -> expire 60; False -> init
rate_limit: int
# Integration only (not forwarded to xknx)
telegram_log_size: int # not required
@@ -156,7 +158,11 @@ SUPPORTED_PLATFORMS_YAML: Final = {
Platform.WEATHER,
}
-SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT}
+SUPPORTED_PLATFORMS_UI: Final = {
+ Platform.BINARY_SENSOR,
+ Platform.LIGHT,
+ Platform.SWITCH,
+}
# Map KNX controller modes to HA modes. This list might not be complete.
CONTROLLER_MODES: Final = {
diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py
index caeaed6da93..b75e1a14f67 100644
--- a/homeassistant/components/knx/datetime.py
+++ b/homeassistant/components/knx/datetime.py
@@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import KNXModule
from .const import (
diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py
index 6574e5d5860..a042c2b4c6b 100644
--- a/homeassistant/components/knx/entity.py
+++ b/homeassistant/components/knx/entity.py
@@ -69,7 +69,7 @@ class _KnxEntityBase(Entity):
"""Request a state update from KNX bus."""
await self._device.sync()
- 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()
diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py
index 82bee48ba69..6585b848d8a 100644
--- a/homeassistant/components/knx/expose.py
+++ b/homeassistant/components/knx/expose.py
@@ -88,7 +88,7 @@ class KNXExposeSensor:
self._remove_listener: Callable[[], None] | None = None
self.device: ExposeSensor = ExposeSensor(
xknx=self.xknx,
- name=f"{self.entity_id}__{self.expose_attribute or "state"}",
+ 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,
diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py
index 8e64b46c890..33edc19fb1c 100644
--- a/homeassistant/components/knx/light.py
+++ b/homeassistant/components/knx/light.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, cast
-from propcache import cached_property
+from propcache.api import cached_property
from xknx import XKNX
from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor
@@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import (
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import KNXModule
from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 73a61be68ee..86c050443e3 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -10,9 +10,9 @@
"iot_class": "local_push",
"loggers": ["xknx", "xknxproject"],
"requirements": [
- "xknx==3.4.0",
+ "xknx==3.5.0",
"xknxproject==3.8.1",
- "knx-frontend==2025.1.18.164225"
+ "knx-frontend==2025.1.30.194235"
],
"single_config_entry": true
}
diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml
new file mode 100644
index 00000000000..a6bbaf18bcb
--- /dev/null
+++ b/homeassistant/components/knx/quality_scale.yaml
@@ -0,0 +1,117 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration is push-based.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name:
+ status: exempt
+ comment: |
+ YAML entities don't support devices. UI entities do and use `has_entity_name`.
+ runtime-data:
+ status: exempt
+ comment: |
+ KNXModule is needed in places where no config_entry handle is available:
+ device_trigger, services, websocket
+ test-before-configure:
+ status: exempt
+ comment: |
+ For automatic connection modes, there has already been successful communication
+ with the KNX interface at the discovery process.
+ For manual tunneling, we avoid making short-lived connections since there seem to be
+ interfaces having troubles with that.
+ For routing, the protocol doesn't provide any means to test since it is connectionless multicast.
+ test-before-setup: done
+ unique-config-entry:
+ status: done
+ comment: Single config entry.
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ The library logs when the connection is lost / reconnected. Individual entities don't.
+ parallel-updates:
+ status: exempt
+ comment: |
+ Integration is push based.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ Integration has no authentication.
+ test-coverage: done
+ # Gold
+ devices:
+ status: exempt
+ comment: |
+ YAML entities don't support devices. UI entities support user-defined devices.
+ diagnostics: done
+ discovery-update-info: todo
+ discovery:
+ status: exempt
+ comment: |
+ KNX doesn't support any provided discovery method.
+ docs-data-update: todo
+ docs-examples: done
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ Devices aren't supported directly since communication is on group address level.
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Devices aren't discoverable in KNX.
+ entity-category:
+ status: exempt
+ comment: |
+ Entity category can be configured by the user.
+ entity-device-class:
+ status: exempt
+ comment: |
+ Entity category can be configured by the user. Proper defaults are determined by configured DPT.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ Since all entities are configured manually, they are enabled by default.
+ entity-translations:
+ status: exempt
+ comment: |
+ Since all entities are configured manually, names are user-defined.
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices:
+ status: exempt
+ comment: |
+ Devices aren't discoverable in KNX. Manual device removal is implemented.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ No HTTP is used.
+ strict-typing: done
diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py
index 9311046e410..c9fe0bfc34e 100644
--- a/homeassistant/components/knx/schema.py
+++ b/homeassistant/components/knx/schema.py
@@ -41,10 +41,12 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
Platform,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA
from .const import (
+ CONF_CONTEXT_TIMEOUT,
+ CONF_IGNORE_INTERNAL_STATE,
CONF_INVERT,
CONF_KNX_EXPOSE,
CONF_PAYLOAD_LENGTH,
@@ -211,14 +213,6 @@ class BinarySensorSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX binary sensors."""
PLATFORM = Platform.BINARY_SENSOR
-
- CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
- CONF_SYNC_STATE = CONF_SYNC_STATE
- CONF_INVERT = CONF_INVERT
- CONF_IGNORE_INTERNAL_STATE = "ignore_internal_state"
- CONF_CONTEXT_TIMEOUT = "context_timeout"
- CONF_RESET_AFTER = CONF_RESET_AFTER
-
DEFAULT_NAME = "KNX Binary Sensor"
ENTITY_SCHEMA = vol.All(
@@ -345,6 +339,10 @@ class ClimateSchema(KNXPlatformSchema):
CONF_FAN_SPEED_MODE = "fan_speed_mode"
CONF_FAN_ZERO_MODE = "fan_zero_mode"
CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address"
+ CONF_SWING_ADDRESS = "swing_address"
+ CONF_SWING_STATE_ADDRESS = "swing_state_address"
+ CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address"
+ CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address"
DEFAULT_NAME = "KNX Climate"
DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010"
@@ -433,6 +431,10 @@ class ClimateSchema(KNXPlatformSchema):
vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
FanZeroMode
),
+ vol.Optional(CONF_SWING_ADDRESS): ga_list_validator,
+ vol.Optional(CONF_SWING_STATE_ADDRESS): ga_list_validator,
+ vol.Optional(CONF_SWING_HORIZONTAL_ADDRESS): ga_list_validator,
+ vol.Optional(CONF_SWING_HORIZONTAL_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_HUMIDITY_STATE_ADDRESS): ga_list_validator,
}
),
diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py
index ed265db4ac7..fa4911aa4b7 100644
--- a/homeassistant/components/knx/sensor.py
+++ b/homeassistant/components/knx/sensor.py
@@ -211,7 +211,7 @@ class KNXSystemSensor(SensorEntity):
return True
return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED
- def after_update_callback(self, _: XknxConnectionState) -> None:
+ def after_update_callback(self, device: 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 6c392902737..f0f760180f4 100644
--- a/homeassistant/components/knx/services.py
+++ b/homeassistant/components/knx/services.py
@@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWri
from homeassistant.const import CONF_TYPE, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service
from .const import (
diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py
index 42b76a5a0fd..cf3f2bb9f95 100644
--- a/homeassistant/components/knx/storage/const.py
+++ b/homeassistant/components/knx/storage/const.py
@@ -10,6 +10,7 @@ CONF_GA_STATE: Final = "state"
CONF_GA_PASSIVE: Final = "passive"
CONF_DPT: Final = "dpt"
+CONF_GA_SENSOR: Final = "ga_sensor"
CONF_GA_SWITCH: Final = "ga_switch"
CONF_GA_COLOR_TEMP: Final = "ga_color_temp"
CONF_COLOR_TEMP_MIN: Final = "color_temp_min"
diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py
index 84854d2ec85..d99ffa86f52 100644
--- a/homeassistant/components/knx/storage/entity_store_schema.py
+++ b/homeassistant/components/knx/storage/entity_store_schema.py
@@ -11,12 +11,15 @@ from homeassistant.const import (
CONF_PLATFORM,
Platform,
)
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA
from homeassistant.helpers.typing import VolDictType, VolSchemaType
from ..const import (
+ CONF_CONTEXT_TIMEOUT,
+ CONF_IGNORE_INTERNAL_STATE,
CONF_INVERT,
+ CONF_RESET_AFTER,
CONF_RESPOND_TO_READ,
CONF_SYNC_STATE,
DOMAIN,
@@ -42,6 +45,7 @@ from .const import (
CONF_GA_RED_BRIGHTNESS,
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
+ CONF_GA_SENSOR,
CONF_GA_STATE,
CONF_GA_SWITCH,
CONF_GA_WHITE_BRIGHTNESS,
@@ -94,6 +98,29 @@ def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType:
}
+BINARY_SENSOR_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA,
+ vol.Required(DOMAIN): {
+ vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True),
+ vol.Required(CONF_RESPOND_TO_READ, default=False): bool,
+ vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator,
+ vol.Optional(CONF_INVERT): selector.BooleanSelector(),
+ vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(),
+ vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ min=0, max=10, step=0.1, unit_of_measurement="s"
+ )
+ ),
+ vol.Optional(CONF_RESET_AFTER): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ min=0, max=10, step=0.1, unit_of_measurement="s"
+ )
+ ),
+ },
+ }
+)
+
SWITCH_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA,
@@ -213,6 +240,9 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All(
cv.key_value_schemas(
CONF_PLATFORM,
{
+ Platform.BINARY_SENSOR: vol.Schema(
+ {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA
+ ),
Platform.SWITCH: vol.Schema(
{vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA
),
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index 80ff1105e15..dadc8e84796 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -3,9 +3,9 @@
"step": {
"connection_type": {
"title": "KNX connection",
- "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.) \n\n 'Tunneling' will connect to a specific KNX IP interface over a tunnel. \n\n 'Routing' will use Multicast to communicate with KNX IP routers.",
+ "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.",
"data": {
- "connection_type": "KNX Connection Type"
+ "connection_type": "KNX connection type"
},
"data_description": {
"connection_type": "Please select the connection type you want to use for your KNX connection."
@@ -33,7 +33,7 @@
"title": "Tunnel settings",
"description": "Please enter the connection information of your tunneling device.",
"data": {
- "tunneling_type": "KNX Tunneling Type",
+ "tunneling_type": "KNX tunneling type",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"route_back": "Route back / NAT mode",
@@ -48,11 +48,11 @@
}
},
"secure_key_source_menu_tunnel": {
- "title": "KNX IP-Secure",
+ "title": "KNX IP Secure",
"description": "How do you want to configure KNX/IP Secure?",
"menu_options": {
- "secure_knxkeys": "Use a `.knxkeys` file providing IP secure keys",
- "secure_tunnel_manual": "Configure IP secure credentials manually"
+ "secure_knxkeys": "Use a `.knxkeys` file providing IP Secure keys",
+ "secure_tunnel_manual": "Configure IP Secure credentials manually"
}
},
"secure_key_source_menu_routing": {
@@ -60,7 +60,7 @@
"description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]",
"menu_options": {
"secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]",
- "secure_routing_manual": "Configure IP secure backbone key manually"
+ "secure_routing_manual": "Configure IP Secure backbone key manually"
}
},
"secure_knxkeys": {
@@ -86,7 +86,7 @@
},
"secure_tunnel_manual": {
"title": "Secure tunnelling",
- "description": "Please enter your IP secure information.",
+ "description": "Please enter your IP Secure information.",
"data": {
"user_id": "User ID",
"user_password": "User password",
@@ -161,7 +161,7 @@
"telegram_log_size": "Telegram history limit"
},
"data_description": {
- "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.",
+ "state_updater": "Sets the default behavior for reading state addresses from the KNX Bus.\nWhen enabled, Home Assistant will monitor each group address and read it from the bus if no value has been received for one hour.\nWhen disabled, state addresses will only be read once after a bus connection is established.\nThis behavior can be overridden for individual entities using the `sync_state` option.",
"rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`",
"telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
}
@@ -443,7 +443,7 @@
},
"entity_id": {
"name": "Entity",
- "description": "Entity id whose state or attribute shall be exposed."
+ "description": "Entity ID whose state or attribute shall be exposed."
},
"attribute": {
"name": "Entity attribute",
diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py
index dcd5f477679..df49c84b6d5 100644
--- a/homeassistant/components/knx/telegrams.py
+++ b/homeassistant/components/knx/telegrams.py
@@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.signal_type import SignalType
from .const import DOMAIN
diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py
index 0283b65f899..6a2224c5561 100644
--- a/homeassistant/components/knx/validation.py
+++ b/homeassistant/components/knx/validation.py
@@ -10,7 +10,7 @@ from xknx.dpt import DPTBase, DPTNumeric, DPTString
from xknx.exceptions import CouldNotParseAddress
from xknx.telegram.address import IndividualAddress, parse_device_group_address
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]:
diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py
index f87b94b23fd..0bd51f27ab6 100644
--- a/homeassistant/components/kodi/config_flow.py
+++ b/homeassistant/components/kodi/config_flow.py
@@ -8,7 +8,6 @@ from typing import Any
from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
@@ -22,6 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_WS_PORT,
@@ -103,7 +103,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_name: str | None = None
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self._host = discovery_info.host
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index cdbe4e334cb..bbddbd9f348 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.network import is_internal_request
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .browse_media import (
build_item_response,
diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py
index c811a073cbb..8360f74ce24 100644
--- a/homeassistant/components/kodi/notify.py
+++ b/homeassistant/components/kodi/notify.py
@@ -24,8 +24,8 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json
index 5b472e0c193..8d5e76df71e 100644
--- a/homeassistant/components/kodi/strings.json
+++ b/homeassistant/components/kodi/strings.json
@@ -41,7 +41,7 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "no_uuid": "Kodi instance does not have a unique id. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version."
+ "no_uuid": "Kodi instance does not have a unique ID. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version."
}
},
"device_automation": {
@@ -57,15 +57,15 @@
"fields": {
"media_type": {
"name": "Media type",
- "description": "Media type identifier. It must be one of SONG or ALBUM."
+ "description": "Media type identifier. It must be one of 'SONG' or 'ALBUM'."
},
"media_id": {
"name": "Media ID",
- "description": "Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library."
+ "description": "Unique ID of the media entry to add (`songid` or albumid`). If not defined, Media name and Artist name are needed to search the Kodi music library."
},
"media_name": {
"name": "Media name",
- "description": "Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist."
+ "description": "Optional media name for filtering media. Can be 'ALL' when Media type is 'ALBUM' and Artist name is specified, to add all songs from one artist."
},
"artist_name": {
"name": "Artist name",
diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py
index 65dd7cf39b3..7f5f4d8abd4 100644
--- a/homeassistant/components/konnected/config_flow.py
+++ b/homeassistant/components/konnected/config_flow.py
@@ -12,7 +12,6 @@ from urllib.parse import urlparse
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA,
BinarySensorDeviceClass,
@@ -40,6 +39,11 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ SsdpServiceInfo,
+)
from .const import (
CONF_ACTIVATION,
@@ -254,7 +258,7 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered konnected panel.
@@ -264,16 +268,16 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.debug(discovery_info)
try:
- if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER:
+ if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER:
return self.async_abort(reason="not_konn_panel")
if not any(
- name in discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
+ name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
for name in KONN_PANEL_MODEL_NAMES
):
_LOGGER.warning(
"Discovered unrecognized Konnected device %s",
- discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "Unknown"),
+ discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"),
)
return self.async_abort(reason="not_konn_panel")
diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py
index fa6aa92856b..5f4393146f0 100644
--- a/homeassistant/components/kostal_plenticore/coordinator.py
+++ b/homeassistant/components/kostal_plenticore/coordinator.py
@@ -101,8 +101,8 @@ class Plenticore:
model=f"{prod1} {prod2}",
name=settings["scb:network"][hostname_id],
sw_version=(
- f'IOC: {device_local["Properties:VersionIOC"]}'
- f' MC: {device_local["Properties:VersionMC"]}'
+ f"IOC: {device_local['Properties:VersionIOC']}"
+ f" MC: {device_local['Properties:VersionMC']}"
),
)
diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py
index dbe57f9a517..0074c3a4344 100644
--- a/homeassistant/components/kwb/sensor.py
+++ b/homeassistant/components/kwb/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py
index d7df7a08e76..2cdf28d5e69 100644
--- a/homeassistant/components/lacrosse/sensor.py
+++ b/homeassistant/components/lacrosse/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py
index ecf30f9a197..75a5c737034 100644
--- a/homeassistant/components/lacrosse_view/config_flow.py
+++ b/homeassistant/components/lacrosse_view/config_flow.py
@@ -40,7 +40,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca
raise InvalidAuth from error
if not locations:
- raise NoLocations(f'No locations found for account {data["username"]}')
+ raise NoLocations(f"No locations found for account {data['username']}")
return locations
diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py
index 900463cff6e..8750d1867e6 100644
--- a/homeassistant/components/lacrosse_view/const.py
+++ b/homeassistant/components/lacrosse_view/const.py
@@ -1,4 +1,4 @@
"""Constants for the LaCrosse View integration."""
DOMAIN = "lacrosse_view"
-SCAN_INTERVAL = 30
+SCAN_INTERVAL = 60
diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json
index 453a0855229..86b2f61a872 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.3"]
+ "requirements": ["lacrosse-view==1.0.4"]
}
diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py
index 5d927c6cc79..87a9824423a 100644
--- a/homeassistant/components/lamarzocco/config_flow.py
+++ b/homeassistant/components/lamarzocco/config_flow.py
@@ -17,7 +17,6 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
async_discovered_service_info,
)
-from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
@@ -43,7 +42,11 @@ from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry
@@ -141,8 +144,17 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=vol.Schema(
{
- vol.Required(CONF_USERNAME): str,
- vol.Required(CONF_PASSWORD): str,
+ vol.Required(CONF_USERNAME): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL, autocomplete="username"
+ )
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
}
),
errors=errors,
@@ -343,13 +355,20 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(
- CONF_USERNAME,
- default=reconfigure_entry.data[CONF_USERNAME],
- ): str,
+ CONF_USERNAME, default=reconfigure_entry.data[CONF_USERNAME]
+ ): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL, autocomplete="username"
+ ),
+ ),
vol.Required(
- CONF_PASSWORD,
- default=reconfigure_entry.data[CONF_PASSWORD],
- ): str,
+ CONF_PASSWORD, default=reconfigure_entry.data[CONF_PASSWORD]
+ ): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ ),
+ ),
}
),
)
diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py
index 2385039f53d..dddca6565e4 100644
--- a/homeassistant/components/lamarzocco/coordinator.py
+++ b/homeassistant/components/lamarzocco/coordinator.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py
index 2acca879d52..406e8e40e92 100644
--- a/homeassistant/components/lamarzocco/sensor.py
+++ b/homeassistant/components/lamarzocco/sensor.py
@@ -3,7 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
-from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey
+from pylamarzocco.const import BoilerType, MachineModel
from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.components.sensor import (
@@ -81,7 +81,7 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
translation_key="drink_stats_coffee",
native_unit_of_measurement="drinks",
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0),
+ value_fn=lambda device: device.statistics.total_coffee,
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
entity_category=EntityCategory.DIAGNOSTIC,
),
diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py
index 779cfa10445..89659fbd2c0 100644
--- a/homeassistant/components/lametric/__init__.py
+++ b/homeassistant/components/lametric/__init__.py
@@ -4,8 +4,7 @@ from homeassistant.components import notify as hass_notify
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py
index 05c5dea77d1..23b0062fc82 100644
--- a/homeassistant/components/lametric/config_flow.py
+++ b/homeassistant/components/lametric/config_flow.py
@@ -23,12 +23,6 @@ from demetriek import (
import voluptuous as vol
from yarl import URL
-from homeassistant.components.dhcp import DhcpServiceInfo
-from homeassistant.components.ssdp import (
- ATTR_UPNP_FRIENDLY_NAME,
- ATTR_UPNP_SERIAL,
- SsdpServiceInfo,
-)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC
from homeassistant.data_entry_flow import AbortFlow
@@ -44,6 +38,12 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from homeassistant.util.network import is_link_local
from .const import DOMAIN, LOGGER
diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py
index 2a952851712..eb331650870 100644
--- a/homeassistant/components/lametric/entity.py
+++ b/homeassistant/components/lametric/entity.py
@@ -30,4 +30,5 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]):
model_id=coordinator.data.model,
name=coordinator.data.name,
sw_version=coordinator.data.os_version,
+ serial_number=coordinator.data.serial_number,
)
diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json
index 01e7823c76b..3c2f05fa535 100644
--- a/homeassistant/components/lametric/strings.json
+++ b/homeassistant/components/lametric/strings.json
@@ -138,7 +138,7 @@
"description": "The message to display."
},
"icon": {
- "name": "Icon",
+ "name": "Icon ID",
"description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: https://developer.lametric.com/icons."
},
"sound": {
diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py
index 6c3cd1922cf..fe486660438 100644
--- a/homeassistant/components/lannouncer/notify.py
+++ b/homeassistant/components/lannouncer/notify.py
@@ -15,7 +15,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
ATTR_METHOD = "method"
diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py
index a8c52b72a81..0680bfc9d71 100644
--- a/homeassistant/components/lawn_mower/__init__.py
+++ b/homeassistant/components/lawn_mower/__init__.py
@@ -6,7 +6,7 @@ from datetime import timedelta
import logging
from typing import final
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py
index 7fbe7e7ac0e..58924413c56 100644
--- a/homeassistant/components/lcn/__init__.py
+++ b/homeassistant/components/lcn/__init__.py
@@ -230,8 +230,6 @@ def async_host_input_received(
)
identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))}
device = device_registry.async_get_device(identifiers=identifiers)
- if device is None:
- return
if isinstance(inp, pypck.inputs.ModStatusAccessControl):
_async_fire_access_control_event(hass, device, address, inp)
@@ -240,7 +238,10 @@ def async_host_input_received(
def _async_fire_access_control_event(
- hass: HomeAssistant, device: dr.DeviceEntry, address: AddressType, inp: InputType
+ hass: HomeAssistant,
+ device: dr.DeviceEntry | None,
+ address: AddressType,
+ inp: InputType,
) -> None:
"""Fire access control event (transponder, transmitter, fingerprint, codelock)."""
event_data = {
@@ -262,7 +263,10 @@ def _async_fire_access_control_event(
def _async_fire_send_keys_event(
- hass: HomeAssistant, device: dr.DeviceEntry, address: AddressType, inp: InputType
+ hass: HomeAssistant,
+ device: dr.DeviceEntry | None,
+ address: AddressType,
+ inp: InputType,
) -> None:
"""Fire send_keys event."""
for table, action in enumerate(inp.actions):
diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py
index 360b732c02e..1dff15c4f22 100644
--- a/homeassistant/components/lcn/climate.py
+++ b/homeassistant/components/lcn/climate.py
@@ -32,6 +32,7 @@ from .const import (
CONF_MAX_TEMP,
CONF_MIN_TEMP,
CONF_SETPOINT,
+ CONF_TARGET_VALUE_LOCKED,
DOMAIN,
)
from .entity import LcnEntity
@@ -93,6 +94,9 @@ class LcnClimate(LcnEntity, ClimateEntity):
self.regulator_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint)
self.is_lockable = config[CONF_DOMAIN_DATA][CONF_LOCKABLE]
+ self.target_value_locked = config[CONF_DOMAIN_DATA].get(
+ CONF_TARGET_VALUE_LOCKED, -1
+ )
self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP]
self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP]
@@ -171,7 +175,9 @@ class LcnClimate(LcnEntity, ClimateEntity):
self._is_on = True
self.async_write_ha_state()
elif hvac_mode == HVACMode.OFF:
- if not await self.device_connection.lock_regulator(self.regulator_id, True):
+ if not await self.device_connection.lock_regulator(
+ self.regulator_id, True, self.target_value_locked
+ ):
return
self._is_on = False
self._target_temperature = None
diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py
index a1be32704f7..63e0d8c8b26 100644
--- a/homeassistant/components/lcn/config_flow.py
+++ b/homeassistant/components/lcn/config_flow.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import PchkConnectionManager
diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py
index cee9da9be43..b443e05def7 100644
--- a/homeassistant/components/lcn/const.py
+++ b/homeassistant/components/lcn/const.py
@@ -35,6 +35,7 @@ CONF_DIMMABLE = "dimmable"
CONF_TRANSITION = "transition"
CONF_MOTOR = "motor"
CONF_LOCKABLE = "lockable"
+CONF_TARGET_VALUE_LOCKED = "target_value_locked"
CONF_VARIABLE = "variable"
CONF_VALUE = "value"
CONF_RELVARREF = "value_reference"
diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py
index 348305c775e..b999c6f3770 100644
--- a/homeassistant/components/lcn/helpers.py
+++ b/homeassistant/components/lcn/helpers.py
@@ -90,9 +90,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
if domain_name == "cover":
return cast(str, domain_data["motor"])
if domain_name == "climate":
- return f'{domain_data["source"]}.{domain_data["setpoint"]}'
+ return f"{domain_data['source']}.{domain_data['setpoint']}"
if domain_name == "scene":
- return f'{domain_data["register"]}.{domain_data["scene"]}'
+ return f"{domain_data['register']}.{domain_data['scene']}"
raise ValueError("Unknown domain")
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
index f5eb1654588..c1dd7751940 100644
--- a/homeassistant/components/lcn/manifest.json
+++ b/homeassistant/components/lcn/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"loggers": ["pypck"],
- "requirements": ["pypck==0.8.1", "lcn-frontend==0.2.2"]
+ "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"]
}
diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py
index c9c91b9843d..d90e264692c 100644
--- a/homeassistant/components/lcn/schemas.py
+++ b/homeassistant/components/lcn/schemas.py
@@ -9,7 +9,7 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
UnitOfTemperature,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -24,6 +24,7 @@ from .const import (
CONF_REGISTER,
CONF_REVERSE_TIME,
CONF_SETPOINT,
+ CONF_TARGET_VALUE_LOCKED,
CONF_TRANSITION,
KEYS,
LED_PORTS,
@@ -58,6 +59,7 @@ DOMAIN_DATA_CLIMATE: VolDictType = {
vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_LOCKABLE, default=False): vol.Coerce(bool),
+ vol.Optional(CONF_TARGET_VALUE_LOCKED, default=-1): vol.Coerce(float),
vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS): vol.In(
UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT
),
diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py
index a6c42de0487..2694bed31d2 100644
--- a/homeassistant/components/lcn/services.py
+++ b/homeassistant/components/lcn/services.py
@@ -20,8 +20,7 @@ from homeassistant.core import (
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import device_registry as dr
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import (
diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json
index 988c2a637fb..0bdd85a3678 100644
--- a/homeassistant/components/lcn/strings.json
+++ b/homeassistant/components/lcn/strings.json
@@ -17,7 +17,7 @@
"config": {
"step": {
"user": {
- "title": "Setup LCN host",
+ "title": "Set up LCN host",
"description": "Set up new connection to LCN host.",
"data": {
"host": "[%key:common::config_flow::data::name%]",
@@ -30,8 +30,14 @@
"acknowledge": "Request acknowledgement from modules"
},
"data_description": {
- "dim_mode": "The number of steps used for dimming outputs.",
- "acknowledge": "Retry sendig commands if no response is received (increases bus traffic)."
+ "host": "Name of the LCN integration entry.",
+ "ip_address": "IP address or hostname of the PCHK server.",
+ "port": "Port used by the PCHK server.",
+ "username": "Username for authorization on the PCHK server.",
+ "password": "Password for authorization on the PCHK server.",
+ "sk_num_tries": "If you have a segment coupler in your LCN installation, increase this number to at least 3, so all segment couplers are identified correctly.",
+ "dim_mode": "The number of steps used for dimming outputs of all LCN modules.",
+ "acknowledge": "Retry sendig commands if no expected response is received from modules (increases bus traffic)."
}
},
"reconfigure": {
@@ -47,6 +53,11 @@
"acknowledge": "[%key:component::lcn::config::step::user::data::acknowledge%]"
},
"data_description": {
+ "ip_address": "[%key:component::lcn::config::step::user::data_description::ip_address%]",
+ "port": "[%key:component::lcn::config::step::user::data_description::port%]",
+ "username": "[%key:component::lcn::config::step::user::data_description::username%]",
+ "password": "[%key:component::lcn::config::step::user::data_description::password%]",
+ "sk_num_tries": "[%key:component::lcn::config::step::user::data_description::sk_num_tries%]",
"dim_mode": "[%key:component::lcn::config::step::user::data_description::dim_mode%]",
"acknowledge": "[%key:component::lcn::config::step::user::data_description::acknowledge%]"
}
@@ -65,15 +76,15 @@
"issues": {
"deprecated_regulatorlock_sensor": {
"title": "Deprecated LCN regulator lock binary sensor",
- "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
+ "description": "Your LCN regulator lock binary sensor entity `{entity}` is being used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
},
"deprecated_keylock_sensor": {
"title": "Deprecated LCN key lock binary sensor",
- "description": "Your LCN key lock binary sensor entity `{entity}` is beeing used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
+ "description": "Your LCN key lock binary sensor entity `{entity}` is being used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
},
"deprecated_address_parameter": {
"title": "Deprecated 'address' parameter",
- "description": "The 'address' parameter in the LCN service calls is deprecated. The 'devide_id' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
+ "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device ID' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
}
},
"services": {
@@ -83,7 +94,7 @@
"fields": {
"device_id": {
"name": "[%key:common::config_flow::data::device%]",
- "description": "The device_id of the LCN module or group."
+ "description": "The device ID of the LCN module or group."
},
"address": {
"name": "Address",
@@ -167,7 +178,7 @@
},
"led": {
"name": "LED",
- "description": "Sets the led state.",
+ "description": "Sets the LED state.",
"fields": {
"device_id": {
"name": "[%key:common::config_flow::data::device%]",
@@ -179,11 +190,11 @@
},
"led": {
"name": "[%key:component::lcn::services::led::name%]",
- "description": "Led."
+ "description": "The LED port of the device."
},
"state": {
"name": "State",
- "description": "Led state."
+ "description": "The LED state to set."
}
}
},
@@ -384,16 +395,16 @@
}
},
"address_to_device_id": {
- "name": "Address to device id",
- "description": "Convert LCN address to device id.",
+ "name": "Address to device ID",
+ "description": "Convert LCN address to device ID.",
"fields": {
"id": {
- "name": "Module or group id",
- "description": "Target module or group id."
+ "name": "Module or group ID",
+ "description": "Target module or group ID."
},
"segment_id": {
- "name": "Segment id",
- "description": "Target segment id."
+ "name": "Segment ID",
+ "description": "Target segment ID."
},
"type": {
"name": "Type",
@@ -408,13 +419,13 @@
},
"exceptions": {
"no_device_identifier": {
- "message": "No device identifier provided. Please provide the device id."
+ "message": "No device identifier provided. Please provide the device ID."
},
"invalid_address": {
"message": "LCN device for given address has not been configured."
},
"invalid_device_id": {
- "message": "LCN device for given device id has not been configured."
+ "message": "LCN device for given device ID has not been configured."
}
}
}
diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py
index d3268dfbf91..9084ec838d9 100644
--- a/homeassistant/components/lcn/websocket.py
+++ b/homeassistant/components/lcn/websocket.py
@@ -22,8 +22,11 @@ from homeassistant.const import (
CONF_RESOURCE,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr, entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from .const import (
ADD_ENTITIES_CALLBACKS,
@@ -423,25 +426,22 @@ async def async_create_or_update_device_in_config_entry(
device_connection.is_group,
)
- device_configs = [*config_entry.data[CONF_DEVICES]]
- data = {**config_entry.data, CONF_DEVICES: device_configs}
- for device_config in data[CONF_DEVICES]:
- if tuple(device_config[CONF_ADDRESS]) == address:
- break # device already in config_entry
- else:
- # create new device_entry
- device_config = {
- CONF_ADDRESS: address,
- CONF_NAME: "",
- CONF_HARDWARE_SERIAL: -1,
- CONF_SOFTWARE_SERIAL: -1,
- CONF_HARDWARE_TYPE: -1,
- }
- data[CONF_DEVICES].append(device_config)
+ device_config = {
+ CONF_ADDRESS: address,
+ CONF_NAME: "",
+ CONF_HARDWARE_SERIAL: -1,
+ CONF_SOFTWARE_SERIAL: -1,
+ CONF_HARDWARE_TYPE: -1,
+ }
+
+ device_configs = [
+ device
+ for device in config_entry.data[CONF_DEVICES]
+ if tuple(device[CONF_ADDRESS]) != address
+ ]
+ data = {**config_entry.data, CONF_DEVICES: [*device_configs, device_config]}
- # update device_entry
await async_update_device_config(device_connection, device_config)
-
hass.config_entries.async_update_entry(config_entry, data=data)
diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json
index d3e21eeae90..36d0150642e 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.20.0", "ld2410-ble==0.1.1"]
+ "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"]
}
diff --git a/homeassistant/components/led_ble/config_flow.py b/homeassistant/components/led_ble/config_flow.py
index 90d86d44160..517fb3759de 100644
--- a/homeassistant/components/led_ble/config_flow.py
+++ b/homeassistant/components/led_ble/config_flow.py
@@ -6,7 +6,7 @@ import logging
from typing import Any
from bluetooth_data_tools import human_readable_name
-from led_ble import BLEAK_EXCEPTIONS, LEDBLE
+from led_ble import BLEAK_EXCEPTIONS, LEDBLE, CharacteristicMissingError
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -66,6 +66,8 @@ class LedBleConfigFlow(ConfigFlow, domain=DOMAIN):
led_ble = LEDBLE(discovery_info.device)
try:
await led_ble.update()
+ except CharacteristicMissingError:
+ return self.async_abort(reason="not_supported")
except BLEAK_EXCEPTIONS:
errors["base"] = "cannot_connect"
except Exception:
diff --git a/homeassistant/components/led_ble/const.py b/homeassistant/components/led_ble/const.py
index 64c28d1ada5..bf4dadd441c 100644
--- a/homeassistant/components/led_ble/const.py
+++ b/homeassistant/components/led_ble/const.py
@@ -5,7 +5,7 @@ from typing import Final
DOMAIN = "led_ble"
DEVICE_TIMEOUT = 30
-LOCAL_NAMES = {"LEDnet", "BLE-LED", "LEDBLE", "Triones", "LEDBlue"}
+LOCAL_NAMES = {"LEDnet", "BLE-LED", "LEDBLE", "Triones", "LEDBlue", "LD-0003"}
UNSUPPORTED_SUB_MODEL = "LEDnetWF"
diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json
index 4aaaebc0006..309399e6958 100644
--- a/homeassistant/components/led_ble/manifest.json
+++ b/homeassistant/components/led_ble/manifest.json
@@ -27,7 +27,7 @@
"local_name": "AP-*"
},
{
- "local_name": "MELK-*"
+ "local_name": "LD-0003"
}
],
"codeowners": ["@bdraco"],
@@ -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.20.0", "led-ble==1.1.1"]
+ "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"]
}
diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py
index 7091856f4fd..0641749a2b9 100644
--- a/homeassistant/components/lektrico/config_flow.py
+++ b/homeassistant/components/lektrico/config_flow.py
@@ -7,7 +7,6 @@ from typing import Any
from lektricowifi import Device, DeviceConnectionError
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
ATTR_HW_VERSION,
@@ -17,6 +16,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -116,7 +116,7 @@ class LektricoFlowHandler(ConfigFlow, domain=DOMAIN):
self._serial_number = str(settings["serial_number"])
self._device_type = settings["type"]
self._board_revision = settings["board_revision"]
- self._name = f"{settings["type"]}_{self._serial_number}"
+ self._name = f"{settings['type']}_{self._serial_number}"
# Check if already configured
# Set unique id
diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py
new file mode 100644
index 00000000000..905887463d7
--- /dev/null
+++ b/homeassistant/components/letpot/__init__.py
@@ -0,0 +1,94 @@
+"""The LetPot integration."""
+
+from __future__ import annotations
+
+import asyncio
+
+from letpot.client import LetPotClient
+from letpot.converters import CONVERTERS
+from letpot.exceptions import LetPotAuthenticationException, LetPotException
+from letpot.models import AuthenticationInfo
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ CONF_ACCESS_TOKEN_EXPIRES,
+ CONF_REFRESH_TOKEN,
+ CONF_REFRESH_TOKEN_EXPIRES,
+ CONF_USER_ID,
+)
+from .coordinator import LetPotDeviceCoordinator
+
+PLATFORMS: list[Platform] = [Platform.TIME]
+
+type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
+ """Set up LetPot from a config entry."""
+
+ auth = AuthenticationInfo(
+ access_token=entry.data[CONF_ACCESS_TOKEN],
+ access_token_expires=entry.data[CONF_ACCESS_TOKEN_EXPIRES],
+ refresh_token=entry.data[CONF_REFRESH_TOKEN],
+ refresh_token_expires=entry.data[CONF_REFRESH_TOKEN_EXPIRES],
+ user_id=entry.data[CONF_USER_ID],
+ email=entry.data[CONF_EMAIL],
+ )
+ websession = async_get_clientsession(hass)
+ client = LetPotClient(websession, auth)
+
+ if not auth.is_valid:
+ try:
+ auth = await client.refresh_token()
+ hass.config_entries.async_update_entry(
+ entry,
+ data={
+ CONF_ACCESS_TOKEN: auth.access_token,
+ CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires,
+ CONF_REFRESH_TOKEN: auth.refresh_token,
+ CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires,
+ CONF_USER_ID: auth.user_id,
+ CONF_EMAIL: auth.email,
+ },
+ )
+ except LetPotAuthenticationException as exc:
+ raise ConfigEntryAuthFailed from exc
+
+ try:
+ devices = await client.get_devices()
+ except LetPotAuthenticationException as exc:
+ raise ConfigEntryAuthFailed from exc
+ except LetPotException as exc:
+ raise ConfigEntryNotReady from exc
+
+ coordinators: list[LetPotDeviceCoordinator] = [
+ LetPotDeviceCoordinator(hass, auth, device)
+ for device in devices
+ if any(converter.supports_type(device.device_type) for converter in CONVERTERS)
+ ]
+
+ await asyncio.gather(
+ *[
+ coordinator.async_config_entry_first_refresh()
+ for coordinator in coordinators
+ ]
+ )
+
+ entry.runtime_data = coordinators
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
+ """Unload a config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ for coordinator in entry.runtime_data:
+ coordinator.device_client.disconnect()
+ return unload_ok
diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py
new file mode 100644
index 00000000000..bc710cd6aef
--- /dev/null
+++ b/homeassistant/components/letpot/config_flow.py
@@ -0,0 +1,135 @@
+"""Config flow for the LetPot integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from letpot.client import LetPotClient
+from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import (
+ CONF_ACCESS_TOKEN_EXPIRES,
+ CONF_REFRESH_TOKEN,
+ CONF_REFRESH_TOKEN_EXPIRES,
+ CONF_USER_ID,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL,
+ ),
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ ),
+ ),
+ }
+)
+STEP_REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD),
+ ),
+ }
+)
+
+
+class LetPotConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for LetPot."""
+
+ VERSION = 1
+
+ async def _async_validate_credentials(
+ self, email: str, password: str
+ ) -> tuple[dict[str, str], dict[str, Any] | None]:
+ """Try logging in to the LetPot account and returns credential info."""
+ websession = async_get_clientsession(self.hass)
+ client = LetPotClient(websession)
+ try:
+ auth = await client.login(email, password)
+ except LetPotConnectionException:
+ return {"base": "cannot_connect"}, None
+ except LetPotAuthenticationException:
+ return {"base": "invalid_auth"}, None
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ return {"base": "unknown"}, None
+ else:
+ return {}, {
+ CONF_ACCESS_TOKEN: auth.access_token,
+ CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires,
+ CONF_REFRESH_TOKEN: auth.refresh_token,
+ CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires,
+ CONF_USER_ID: auth.user_id,
+ CONF_EMAIL: auth.email,
+ }
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ errors, data_dict = await self._async_validate_credentials(
+ user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
+ )
+ if not errors and data_dict is not None:
+ await self.async_set_unique_id(data_dict[CONF_USER_ID])
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=data_dict[CONF_EMAIL], data=data_dict
+ )
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ errors, data_dict = await self._async_validate_credentials(
+ reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD]
+ )
+ if not errors and data_dict is not None:
+ await self.async_set_unique_id(data_dict[CONF_USER_ID])
+ if reauth_entry.unique_id != data_dict[CONF_USER_ID]:
+ # Abort if the received account is different and already added
+ self._abort_if_unique_id_configured()
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ unique_id=self.unique_id,
+ data_updates=data_dict,
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=STEP_REAUTH_SCHEMA,
+ description_placeholders={"email": reauth_entry.title},
+ errors=errors,
+ )
diff --git a/homeassistant/components/letpot/const.py b/homeassistant/components/letpot/const.py
new file mode 100644
index 00000000000..af01bbfdffc
--- /dev/null
+++ b/homeassistant/components/letpot/const.py
@@ -0,0 +1,10 @@
+"""Constants for the LetPot integration."""
+
+DOMAIN = "letpot"
+
+CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires"
+CONF_REFRESH_TOKEN = "refresh_token"
+CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires"
+CONF_USER_ID = "user_id"
+
+REQUEST_UPDATE_TIMEOUT = 10
diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py
new file mode 100644
index 00000000000..a2a35d566c6
--- /dev/null
+++ b/homeassistant/components/letpot/coordinator.py
@@ -0,0 +1,67 @@
+"""Coordinator for the LetPot integration."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING
+
+from letpot.deviceclient import LetPotDeviceClient
+from letpot.exceptions import LetPotAuthenticationException, LetPotException
+from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import REQUEST_UPDATE_TIMEOUT
+
+if TYPE_CHECKING:
+ from . import LetPotConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
+ """Class to handle data updates for a specific garden."""
+
+ config_entry: LetPotConfigEntry
+
+ device: LetPotDevice
+ device_client: LetPotDeviceClient
+
+ def __init__(
+ self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice
+ ) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=f"LetPot {device.serial_number}",
+ )
+ self._info = info
+ self.device = device
+ self.device_client = LetPotDeviceClient(info, device.serial_number)
+
+ def _handle_status_update(self, status: LetPotDeviceStatus) -> None:
+ """Distribute status update to entities."""
+ self.async_set_updated_data(data=status)
+
+ async def _async_setup(self) -> None:
+ """Set up subscription for coordinator."""
+ try:
+ await self.device_client.subscribe(self._handle_status_update)
+ except LetPotAuthenticationException as exc:
+ raise ConfigEntryAuthFailed from exc
+
+ async def _async_update_data(self) -> LetPotDeviceStatus:
+ """Request an update from the device and wait for a status update or timeout."""
+ try:
+ async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT):
+ await self.device_client.get_current_status()
+ except LetPotException as exc:
+ raise UpdateFailed(exc) from exc
+
+ # The subscription task will have updated coordinator.data, so return that data.
+ # If we don't return anything here, coordinator.data will be set to None.
+ return self.data
diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py
new file mode 100644
index 00000000000..c9a8953b5d5
--- /dev/null
+++ b/homeassistant/components/letpot/entity.py
@@ -0,0 +1,25 @@
+"""Base class for LetPot entities."""
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import LetPotDeviceCoordinator
+
+
+class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
+ """Defines a base LetPot entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: LetPotDeviceCoordinator) -> None:
+ """Initialize a LetPot entity."""
+ super().__init__(coordinator)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.device.serial_number)},
+ name=coordinator.device.name,
+ manufacturer="LetPot",
+ model=coordinator.device_client.device_model_name,
+ model_id=coordinator.device_client.device_model_code,
+ serial_number=coordinator.device.serial_number,
+ )
diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json
new file mode 100644
index 00000000000..691584abc13
--- /dev/null
+++ b/homeassistant/components/letpot/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "letpot",
+ "name": "LetPot",
+ "codeowners": ["@jpelgrom"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/letpot",
+ "integration_type": "hub",
+ "iot_class": "cloud_push",
+ "quality_scale": "bronze",
+ "requirements": ["letpot==0.3.0"]
+}
diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml
new file mode 100644
index 00000000000..74b948ffbf7
--- /dev/null
+++ b/homeassistant/components/letpot/quality_scale.yaml
@@ -0,0 +1,75 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration only receives push-based updates.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading:
+ status: done
+ comment: |
+ Push connection connects in coordinator _async_setup, disconnects in init async_unload_entry.
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ The integration does not have configuration options.
+ docs-installation-parameters: done
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json
new file mode 100644
index 00000000000..93913c2bc4d
--- /dev/null
+++ b/homeassistant/components/letpot/strings.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "The email address of your LetPot account.",
+ "password": "The password of your LetPot account."
+ }
+ },
+ "reauth_confirm": {
+ "description": "The LetPot integration needs to re-authenticate your account {email}.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::letpot::config::step::user::data_description::password%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "entity": {
+ "time": {
+ "light_schedule_end": {
+ "name": "Light off"
+ },
+ "light_schedule_start": {
+ "name": "Light on"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py
new file mode 100644
index 00000000000..229f02e0806
--- /dev/null
+++ b/homeassistant/components/letpot/time.py
@@ -0,0 +1,93 @@
+"""Support for LetPot time entities."""
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from datetime import time
+from typing import Any
+
+from letpot.deviceclient import LetPotDeviceClient
+from letpot.models import LetPotDeviceStatus
+
+from homeassistant.components.time import TimeEntity, TimeEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import LetPotConfigEntry
+from .coordinator import LetPotDeviceCoordinator
+from .entity import LetPotEntity
+
+# Each change pushes a 'full' device status with the change. The library will cache
+# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class LetPotTimeEntityDescription(TimeEntityDescription):
+ """Describes a LetPot time entity."""
+
+ value_fn: Callable[[LetPotDeviceStatus], time | None]
+ set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]]
+
+
+TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
+ LetPotTimeEntityDescription(
+ key="light_schedule_end",
+ translation_key="light_schedule_end",
+ value_fn=lambda status: None if status is None else status.light_schedule_end,
+ set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
+ start=None, end=value
+ ),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ LetPotTimeEntityDescription(
+ key="light_schedule_start",
+ translation_key="light_schedule_start",
+ value_fn=lambda status: None if status is None else status.light_schedule_start,
+ set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
+ start=value, end=None
+ ),
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LetPotConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up LetPot time entities based on a config entry."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ LetPotTimeEntity(coordinator, description)
+ for description in TIME_SENSORS
+ for coordinator in coordinators
+ )
+
+
+class LetPotTimeEntity(LetPotEntity, TimeEntity):
+ """Defines a LetPot time entity."""
+
+ entity_description: LetPotTimeEntityDescription
+
+ def __init__(
+ self,
+ coordinator: LetPotDeviceCoordinator,
+ description: LetPotTimeEntityDescription,
+ ) -> None:
+ """Initialize LetPot time entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
+
+ @property
+ def native_value(self) -> time | None:
+ """Return the time."""
+ return self.entity_description.value_fn(self.coordinator.data)
+
+ async def async_set_value(self, value: time) -> None:
+ """Set the time."""
+ await self.entity_description.set_value_fn(
+ self.coordinator.device_client, value
+ )
diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py
index 8759869aad3..025f80f78b1 100644
--- a/homeassistant/components/lg_thinq/mqtt.py
+++ b/homeassistant/components/lg_thinq/mqtt.py
@@ -168,7 +168,7 @@ class ThinQMQTT:
async def async_handle_device_event(self, message: dict) -> None:
"""Handle received mqtt message."""
unique_id = (
- f"{message["deviceId"]}_{list(message["report"].keys())[0]}"
+ f"{message['deviceId']}_{list(message['report'].keys())[0]}"
if message["deviceType"] == DeviceType.WASHTOWER
else message["deviceId"]
)
diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py
index 99b4df8176e..7baaab52403 100644
--- a/homeassistant/components/lg_thinq/sensor.py
+++ b/homeassistant/components/lg_thinq/sensor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from datetime import datetime, time, timedelta
import logging
from thinqconnect import DeviceType
@@ -22,6 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import dt as dt_util
from . import ThinqConfigEntry
from .coordinator import DeviceDataUpdateCoordinator
@@ -93,6 +95,11 @@ FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
native_unit_of_measurement=UnitOfTime.HOURS,
translation_key=ThinQProperty.FILTER_LIFETIME,
),
+ ThinQProperty.FILTER_REMAIN_PERCENT: SensorEntityDescription(
+ key=ThinQProperty.FILTER_REMAIN_PERCENT,
+ native_unit_of_measurement=PERCENTAGE,
+ translation_key=ThinQProperty.FILTER_LIFETIME,
+ ),
}
HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription(
@@ -255,9 +262,90 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
translation_key=ThinQProperty.WATER_TYPE,
),
}
+ELAPSED_DAY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
+ ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription(
+ key=ThinQProperty.ELAPSED_DAY_STATE,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.DAYS,
+ translation_key=ThinQProperty.ELAPSED_DAY_STATE,
+ ),
+ ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription(
+ key=ThinQProperty.ELAPSED_DAY_TOTAL,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.DAYS,
+ translation_key=ThinQProperty.ELAPSED_DAY_TOTAL,
+ ),
+}
+TIME_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
+ TimerProperty.LIGHT_START: SensorEntityDescription(
+ key=TimerProperty.LIGHT_START,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.LIGHT_START,
+ ),
+ TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription(
+ key=TimerProperty.ABSOLUTE_TO_START,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.ABSOLUTE_TO_START,
+ ),
+ TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription(
+ key=TimerProperty.ABSOLUTE_TO_STOP,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.ABSOLUTE_TO_STOP,
+ ),
+}
+TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
+ TimerProperty.TOTAL: SensorEntityDescription(
+ key=TimerProperty.TOTAL,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ translation_key=TimerProperty.TOTAL,
+ ),
+ TimerProperty.RELATIVE_TO_START: SensorEntityDescription(
+ key=TimerProperty.RELATIVE_TO_START,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ translation_key=TimerProperty.RELATIVE_TO_START,
+ ),
+ TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription(
+ key=TimerProperty.RELATIVE_TO_STOP,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ translation_key=TimerProperty.RELATIVE_TO_STOP,
+ ),
+ TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription(
+ key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP,
+ ),
+ TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription(
+ key=TimerProperty.RELATIVE_TO_START,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.RELATIVE_TO_START_WM,
+ ),
+ TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription(
+ key=TimerProperty.RELATIVE_TO_STOP,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.RELATIVE_TO_STOP_WM,
+ ),
+ TimerProperty.REMAIN: SensorEntityDescription(
+ key=TimerProperty.REMAIN,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.REMAIN,
+ ),
+ TimerProperty.RUNNING: SensorEntityDescription(
+ key=TimerProperty.RUNNING,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ translation_key=TimerProperty.RUNNING,
+ ),
+}
WASHER_SENSORS: tuple[SensorEntityDescription, ...] = (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
+ TIMER_SENSOR_DESC[TimerProperty.TOTAL],
+ TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
+ TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM],
+ TIMER_SENSOR_DESC[TimerProperty.REMAIN],
)
DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = {
DeviceType.AIR_CONDITIONER: (
@@ -268,6 +356,12 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME],
+ FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT],
+ TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START],
+ TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP],
+ TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP],
),
DeviceType.AIR_PURIFIER_FAN: (
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
@@ -278,6 +372,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
+ TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP],
),
DeviceType.AIR_PURIFIER: (
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
@@ -287,8 +384,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
+ FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT],
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP],
),
DeviceType.COOKTOP: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
@@ -303,6 +403,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL],
PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL],
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
+ TIMER_SENSOR_DESC[TimerProperty.TOTAL],
+ TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
+ TIMER_SENSOR_DESC[TimerProperty.REMAIN],
),
DeviceType.DRYER: WASHER_SENSORS,
DeviceType.HOME_BREW: (
@@ -313,6 +416,8 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO],
RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN],
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
+ ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE],
+ ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL],
),
DeviceType.HUMIDIFIER: (
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
@@ -322,6 +427,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
+ TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP],
),
DeviceType.KIMCHI_REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
@@ -344,6 +452,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE],
TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE],
TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE],
+ TIME_SENSOR_DESC[TimerProperty.LIGHT_START],
),
DeviceType.REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
@@ -352,6 +461,8 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
DeviceType.ROBOT_CLEANER: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
+ TIMER_SENSOR_DESC[TimerProperty.RUNNING],
+ TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
),
DeviceType.STICK_CLEANER: (
BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT],
@@ -426,11 +537,59 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
if entity_description.device_class == SensorDeviceClass.ENUM:
self._attr_options = self.data.options
+ self._device_state: str | None = None
+ self._device_state_id = (
+ ThinQProperty.CURRENT_STATE
+ if self.location is None
+ else f"{self.location}_{ThinQProperty.CURRENT_STATE}"
+ )
+
def _update_status(self) -> None:
"""Update status itself."""
super()._update_status()
- self._attr_native_value = self.data.value
+ value = self.data.value
+
+ if isinstance(value, time):
+ local_now = datetime.now(
+ tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
+ )
+ if value in [0, None, time.min]:
+ # Reset to None
+ value = None
+ elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
+ if self.entity_description.key in TIME_SENSOR_DESC:
+ # Set timestamp for time
+ value = local_now.replace(hour=value.hour, minute=value.minute)
+ else:
+ # Set timestamp for delta
+ new_state = (
+ self.coordinator.data[self._device_state_id].value
+ if self._device_state_id in self.coordinator.data
+ else None
+ )
+ if (
+ self.native_value is not None
+ and self._device_state == new_state
+ ):
+ # Skip update when same state
+ return
+
+ self._device_state = new_state
+ time_delta = timedelta(
+ hours=value.hour, minutes=value.minute, seconds=value.second
+ )
+ value = (
+ (local_now - time_delta)
+ if self.entity_description.key == TimerProperty.RUNNING
+ else (local_now + time_delta)
+ )
+ elif self.entity_description.device_class == SensorDeviceClass.DURATION:
+ # Set duration
+ value = self._get_duration(
+ value, self.entity_description.native_unit_of_measurement
+ )
+ self._attr_native_value = value
if (data_unit := self._get_unit_of_measurement(self.data.unit)) is not None:
# For different from description's unit
@@ -445,3 +604,10 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
self.options,
self.native_unit_of_measurement,
)
+
+ def _get_duration(self, data: time, unit: str | None) -> float | None:
+ if unit == UnitOfTime.MINUTES:
+ return (data.hour * 60) + data.minute
+ if unit == UnitOfTime.SECONDS:
+ return (data.hour * 3600) + (data.minute * 60) + data.second
+ return 0
diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py
index b02361e65ca..805fcce53ad 100644
--- a/homeassistant/components/lidarr/sensor.py
+++ b/homeassistant/components/lidarr/sensor.py
@@ -160,10 +160,8 @@ class LidarrSensor(LidarrEntity[T], SensorEntity):
def queue_str(item: LidarrQueueItem) -> str:
"""Return string description of queue item."""
- if (
- item.sizeleft > 0
- and item.timeleft == "00:00:00"
- or not hasattr(item, "trackedDownloadState")
+ if (item.sizeleft > 0 and item.timeleft == "00:00:00") or not hasattr(
+ item, "trackedDownloadState"
):
return "stopped"
return item.trackedDownloadState
diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py
index 974292c6e80..2847862029f 100644
--- a/homeassistant/components/lifx/__init__.py
+++ b/homeassistant/components/lifx/__init__.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py
index 053bb72c4fd..ee55a7589e2 100644
--- a/homeassistant/components/lifx/config_flow.py
+++ b/homeassistant/components/lifx/config_flow.py
@@ -9,12 +9,12 @@ from aiolifx.aiolifx import Light
from aiolifx.connection import LIFXConnection
import voluptuous as vol
-from homeassistant.components import zeroconf
-from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import (
@@ -72,7 +72,7 @@ class LifXConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_handle_discovery(host)
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle HomeKit discovery."""
return await self._async_handle_discovery(host=discovery_info.host)
diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py
index 667afe1125d..58c3550b812 100644
--- a/homeassistant/components/lifx/const.py
+++ b/homeassistant/components/lifx/const.py
@@ -61,7 +61,7 @@ INFRARED_BRIGHTNESS_VALUES_MAP = {
}
DATA_LIFX_MANAGER = "lifx_manager"
-LIFX_CEILING_PRODUCT_IDS = {176, 177}
+LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202}
_LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py
index 41fa04057f7..eaaff7e6540 100644
--- a/homeassistant/components/lifx/coordinator.py
+++ b/homeassistant/components/lifx/coordinator.py
@@ -21,7 +21,7 @@ from aiolifx.aiolifx import (
from aiolifx.connection import LIFXConnection
from aiolifx_themes.themes import ThemeLibrary, ThemePainter
from awesomeversion import AwesomeVersion
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
@@ -83,7 +83,7 @@ class SkyType(IntEnum):
CLOUDS = 2
-class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904
+class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator to gather data for a specific lifx device."""
def __init__(
@@ -456,7 +456,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904
)
self.active_effect = FirmwareEffect[effect.upper()]
- async def async_set_matrix_effect( # noqa: PLR0917
+ async def async_set_matrix_effect(
self,
effect: str,
palette: list[tuple[int, int, int, int]] | None = None,
diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json
index 58a7c89e266..c37d7641717 100644
--- a/homeassistant/components/lifx/icons.json
+++ b/homeassistant/components/lifx/icons.json
@@ -26,6 +26,9 @@
},
"effect_stop": {
"service": "mdi:stop"
+ },
+ "paint_theme": {
+ "service": "mdi:palette"
}
}
}
diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py
index 22bcef4915e..2a8031b3874 100644
--- a/homeassistant/components/lifx/light.py
+++ b/homeassistant/components/lifx/light.py
@@ -21,8 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+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
diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py
index 27e62717e96..887bc3c3527 100644
--- a/homeassistant/components/lifx/manager.py
+++ b/homeassistant/components/lifx/manager.py
@@ -8,6 +8,7 @@ from datetime import timedelta
from typing import Any
import aiolifx_effects
+from aiolifx_themes.painter import ThemePainter
from aiolifx_themes.themes import Theme, ThemeLibrary
import voluptuous as vol
@@ -26,7 +27,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN
@@ -42,6 +43,7 @@ SERVICE_EFFECT_MOVE = "effect_move"
SERVICE_EFFECT_PULSE = "effect_pulse"
SERVICE_EFFECT_SKY = "effect_sky"
SERVICE_EFFECT_STOP = "effect_stop"
+SERVICE_PAINT_THEME = "paint_theme"
ATTR_CHANGE = "change"
ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min"
@@ -83,6 +85,8 @@ EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180
EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"]
+PAINT_THEME_DEFAULT_TRANSITION = 1
+
PULSE_MODE_BLINK = "blink"
PULSE_MODE_BREATHE = "breathe"
PULSE_MODE_PING = "ping"
@@ -201,17 +205,30 @@ LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema(
}
)
-
-SERVICES = (
- SERVICE_EFFECT_COLORLOOP,
- SERVICE_EFFECT_FLAME,
- SERVICE_EFFECT_MORPH,
- SERVICE_EFFECT_MOVE,
- SERVICE_EFFECT_PULSE,
- SERVICE_EFFECT_SKY,
- SERVICE_EFFECT_STOP,
+LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema(
+ {
+ **LIFX_EFFECT_SCHEMA,
+ ATTR_TRANSITION: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3600)),
+ vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional(
+ vol.In(ThemeLibrary().themes)
+ ),
+ vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All(
+ cv.ensure_list, [HSBK_SCHEMA]
+ ),
+ }
)
+SERVICES_SCHEMA = {
+ SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA,
+ SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA,
+ SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA,
+ SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA,
+ SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA,
+ SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA,
+ SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA,
+ SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA,
+}
+
class LIFXManager:
"""Representation of all known LIFX entities."""
@@ -225,7 +242,7 @@ class LIFXManager:
@callback
def async_unload(self) -> None:
"""Release resources."""
- for service in SERVICES:
+ for service in SERVICES_SCHEMA:
self.hass.services.async_remove(DOMAIN, service)
@callback
@@ -253,54 +270,218 @@ class LIFXManager:
if all_referenced:
await self.start_effect(all_referenced, service.service, **service.data)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_PULSE,
- service_handler,
- schema=LIFX_EFFECT_PULSE_SCHEMA,
+ for service, schema in SERVICES_SCHEMA.items():
+ self.hass.services.async_register(
+ DOMAIN, service, service_handler, schema=schema
+ )
+
+ @staticmethod
+ def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme:
+ """Either return the predefined theme or build one from the palette."""
+ if palette is None:
+ return ThemeLibrary().get_theme(theme_name)
+
+ theme = Theme()
+ for hsbk in palette:
+ theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3])
+ return theme
+
+ async def _start_effect_flame(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Start the firmware-based Flame effect."""
+
+ await asyncio.gather(
+ *(
+ coordinator.async_set_matrix_effect(
+ effect=EFFECT_FLAME,
+ speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED),
+ power_on=kwargs.get(ATTR_POWER_ON, True),
+ )
+ for coordinator in coordinators
+ )
)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_COLORLOOP,
- service_handler,
- schema=LIFX_EFFECT_COLORLOOP_SCHEMA,
+ async def _start_paint_theme(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Paint a theme across one or more LIFX bulbs."""
+ theme_name = kwargs.get(ATTR_THEME, "exciting")
+ palette = kwargs.get(ATTR_PALETTE)
+
+ theme = self.build_theme(theme_name, palette)
+
+ await ThemePainter(self.hass.loop).paint(
+ theme,
+ bulbs,
+ duration=kwargs.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION),
+ power_on=kwargs.get(ATTR_POWER_ON, True),
)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_FLAME,
- service_handler,
- schema=LIFX_EFFECT_FLAME_SCHEMA,
+ async def _start_effect_morph(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Start the firmware-based Morph effect."""
+ theme_name = kwargs.get(ATTR_THEME, "exciting")
+ palette = kwargs.get(ATTR_PALETTE)
+
+ theme = self.build_theme(theme_name, palette)
+
+ await asyncio.gather(
+ *(
+ coordinator.async_set_matrix_effect(
+ effect=EFFECT_MORPH,
+ speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED),
+ palette=theme.colors,
+ power_on=kwargs.get(ATTR_POWER_ON, True),
+ )
+ for coordinator in coordinators
+ )
)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_MORPH,
- service_handler,
- schema=LIFX_EFFECT_MORPH_SCHEMA,
+ async def _start_effect_move(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Start the firmware-based Move effect."""
+ await asyncio.gather(
+ *(
+ coordinator.async_set_multizone_effect(
+ effect=EFFECT_MOVE,
+ speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED),
+ direction=kwargs.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION),
+ theme_name=kwargs.get(ATTR_THEME),
+ power_on=kwargs.get(ATTR_POWER_ON, False),
+ )
+ for coordinator in coordinators
+ )
)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_MOVE,
- service_handler,
- schema=LIFX_EFFECT_MOVE_SCHEMA,
+ async def _start_effect_pulse(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Start the software-based Pulse effect."""
+ effect = aiolifx_effects.EffectPulse(
+ power_on=bool(kwargs.get(ATTR_POWER_ON)),
+ period=kwargs.get(ATTR_PERIOD),
+ cycles=kwargs.get(ATTR_CYCLES),
+ mode=kwargs.get(ATTR_MODE),
+ hsbk=find_hsbk(self.hass, **kwargs),
+ )
+ await self.effects_conductor.start(effect, bulbs)
+
+ async def _start_effect_colorloop(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Start the software based Color Loop effect."""
+ brightness = None
+ saturation_max = None
+ saturation_min = None
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
+ elif ATTR_BRIGHTNESS_PCT in kwargs:
+ brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100))
+
+ if ATTR_SATURATION_MAX in kwargs:
+ saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535)
+
+ if ATTR_SATURATION_MIN in kwargs:
+ saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535)
+
+ effect = aiolifx_effects.EffectColorloop(
+ power_on=bool(kwargs.get(ATTR_POWER_ON)),
+ period=kwargs.get(ATTR_PERIOD),
+ change=kwargs.get(ATTR_CHANGE),
+ spread=kwargs.get(ATTR_SPREAD),
+ transition=kwargs.get(ATTR_TRANSITION),
+ brightness=brightness,
+ saturation_max=saturation_max,
+ saturation_min=saturation_min,
+ )
+ await self.effects_conductor.start(effect, bulbs)
+
+ async def _start_effect_sky(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Start the firmware-based Sky effect."""
+ palette = kwargs.get(ATTR_PALETTE)
+ if palette is not None:
+ theme = Theme()
+ for hsbk in palette:
+ theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3])
+
+ speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED)
+ sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE)
+
+ cloud_saturation_min = kwargs.get(
+ ATTR_CLOUD_SATURATION_MIN,
+ EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN,
+ )
+ cloud_saturation_max = kwargs.get(
+ ATTR_CLOUD_SATURATION_MAX,
+ EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX,
)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_SKY,
- service_handler,
- schema=LIFX_EFFECT_SKY_SCHEMA,
+ await asyncio.gather(
+ *(
+ coordinator.async_set_matrix_effect(
+ effect=EFFECT_SKY,
+ speed=speed,
+ sky_type=sky_type,
+ cloud_saturation_min=cloud_saturation_min,
+ cloud_saturation_max=cloud_saturation_max,
+ palette=theme.colors,
+ )
+ for coordinator in coordinators
+ )
)
- self.hass.services.async_register(
- DOMAIN,
- SERVICE_EFFECT_STOP,
- service_handler,
- schema=LIFX_EFFECT_STOP_SCHEMA,
- )
+ async def _start_effect_stop(
+ self,
+ bulbs: list[Light],
+ coordinators: list[LIFXUpdateCoordinator],
+ **kwargs: Any,
+ ) -> None:
+ """Stop any running software or firmware effect."""
+ await self.effects_conductor.stop(bulbs)
+
+ for coordinator in coordinators:
+ await coordinator.async_set_matrix_effect(effect=EFFECT_OFF, power_on=False)
+ await coordinator.async_set_multizone_effect(
+ effect=EFFECT_OFF, power_on=False
+ )
+
+ _effect_dispatch = {
+ SERVICE_EFFECT_COLORLOOP: _start_effect_colorloop,
+ SERVICE_EFFECT_FLAME: _start_effect_flame,
+ SERVICE_EFFECT_MORPH: _start_effect_morph,
+ SERVICE_EFFECT_MOVE: _start_effect_move,
+ SERVICE_EFFECT_PULSE: _start_effect_pulse,
+ SERVICE_EFFECT_SKY: _start_effect_sky,
+ SERVICE_EFFECT_STOP: _start_effect_stop,
+ SERVICE_PAINT_THEME: _start_paint_theme,
+ }
async def start_effect(
self, entity_ids: set[str], service: str, **kwargs: Any
@@ -318,137 +499,5 @@ class LIFXManager:
coordinators.append(coordinator)
bulbs.append(coordinator.device)
- if service == SERVICE_EFFECT_FLAME:
- await asyncio.gather(
- *(
- coordinator.async_set_matrix_effect(
- effect=EFFECT_FLAME,
- speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED),
- power_on=kwargs.get(ATTR_POWER_ON, True),
- )
- for coordinator in coordinators
- )
- )
-
- elif service == SERVICE_EFFECT_MORPH:
- theme_name = kwargs.get(ATTR_THEME, "exciting")
- palette = kwargs.get(ATTR_PALETTE)
-
- if palette is not None:
- theme = Theme()
- for hsbk in palette:
- theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3])
- else:
- theme = ThemeLibrary().get_theme(theme_name)
-
- await asyncio.gather(
- *(
- coordinator.async_set_matrix_effect(
- effect=EFFECT_MORPH,
- speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED),
- palette=theme.colors,
- power_on=kwargs.get(ATTR_POWER_ON, True),
- )
- for coordinator in coordinators
- )
- )
-
- elif service == SERVICE_EFFECT_MOVE:
- await asyncio.gather(
- *(
- coordinator.async_set_multizone_effect(
- effect=EFFECT_MOVE,
- speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED),
- direction=kwargs.get(
- ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION
- ),
- theme_name=kwargs.get(ATTR_THEME),
- power_on=kwargs.get(ATTR_POWER_ON, False),
- )
- for coordinator in coordinators
- )
- )
-
- elif service == SERVICE_EFFECT_PULSE:
- effect = aiolifx_effects.EffectPulse(
- power_on=kwargs.get(ATTR_POWER_ON),
- period=kwargs.get(ATTR_PERIOD),
- cycles=kwargs.get(ATTR_CYCLES),
- mode=kwargs.get(ATTR_MODE),
- hsbk=find_hsbk(self.hass, **kwargs),
- )
- await self.effects_conductor.start(effect, bulbs)
-
- elif service == SERVICE_EFFECT_COLORLOOP:
- brightness = None
- saturation_max = None
- saturation_min = None
-
- if ATTR_BRIGHTNESS in kwargs:
- brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
- elif ATTR_BRIGHTNESS_PCT in kwargs:
- brightness = convert_8_to_16(
- round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)
- )
-
- if ATTR_SATURATION_MAX in kwargs:
- saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535)
-
- if ATTR_SATURATION_MIN in kwargs:
- saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535)
-
- effect = aiolifx_effects.EffectColorloop(
- power_on=kwargs.get(ATTR_POWER_ON),
- period=kwargs.get(ATTR_PERIOD),
- change=kwargs.get(ATTR_CHANGE),
- spread=kwargs.get(ATTR_SPREAD),
- transition=kwargs.get(ATTR_TRANSITION),
- brightness=brightness,
- saturation_max=saturation_max,
- saturation_min=saturation_min,
- )
- await self.effects_conductor.start(effect, bulbs)
-
- elif service == SERVICE_EFFECT_SKY:
- palette = kwargs.get(ATTR_PALETTE)
- if palette is not None:
- theme = Theme()
- for hsbk in palette:
- theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3])
-
- speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED)
- sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE)
-
- cloud_saturation_min = kwargs.get(
- ATTR_CLOUD_SATURATION_MIN,
- EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN,
- )
- cloud_saturation_max = kwargs.get(
- ATTR_CLOUD_SATURATION_MAX,
- EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX,
- )
-
- await asyncio.gather(
- *(
- coordinator.async_set_matrix_effect(
- effect=EFFECT_SKY,
- speed=speed,
- sky_type=sky_type,
- cloud_saturation_min=cloud_saturation_min,
- cloud_saturation_max=cloud_saturation_max,
- palette=theme.colors,
- )
- for coordinator in coordinators
- )
- )
-
- elif service == SERVICE_EFFECT_STOP:
- await self.effects_conductor.stop(bulbs)
-
- for coordinator in coordinators:
- await coordinator.async_set_matrix_effect(
- effect=EFFECT_OFF, power_on=False
- )
- await coordinator.async_set_multizone_effect(
- effect=EFFECT_OFF, power_on=False
- )
+ if start_effect_func := self._effect_dispatch.get(service):
+ await start_effect_func(self, bulbs, coordinators, **kwargs)
diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json
index 9940ee15dca..8d460c25322 100644
--- a/homeassistant/components/lifx/manifest.json
+++ b/homeassistant/components/lifx/manifest.json
@@ -53,6 +53,6 @@
"requirements": [
"aiolifx==1.1.2",
"aiolifx-effects==0.3.2",
- "aiolifx-themes==0.6.0"
+ "aiolifx-themes==0.6.4"
]
}
diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml
index c2eb2e249cb..ac4fbfc15af 100644
--- a/homeassistant/components/lifx/services.yaml
+++ b/homeassistant/components/lifx/services.yaml
@@ -186,28 +186,46 @@ effect_move:
options:
- "autumn"
- "blissful"
+ - "bias_lighting"
+ - "calaveras"
- "cheerful"
+ - "christmas"
- "dream"
- "energizing"
- "epic"
+ - "evening"
- "exciting"
+ - "fantasy"
- "focusing"
+ - "gentle"
- "halloween"
- "hanukkah"
- "holly"
- - "independence_day"
+ - "hygge"
+ - "independence"
- "intense"
+ - "love"
+ - "kwanzaa"
- "mellow"
+ - "party"
- "peaceful"
- "powerful"
+ - "proud"
+ - "pumpkin"
- "relaxing"
+ - "romance"
- "santa"
- "serene"
+ - "shamrock"
- "soothing"
+ - "spacey"
- "sports"
- "spring"
+ - "stardust"
+ - "thanksgiving"
- "tranquil"
- "warming"
+ - "zombie"
power_on:
default: true
selector:
@@ -255,28 +273,46 @@ effect_morph:
options:
- "autumn"
- "blissful"
+ - "bias_lighting"
+ - "calaveras"
- "cheerful"
+ - "christmas"
- "dream"
- "energizing"
- "epic"
+ - "evening"
- "exciting"
+ - "fantasy"
- "focusing"
+ - "gentle"
- "halloween"
- "hanukkah"
- "holly"
- - "independence_day"
+ - "hygge"
+ - "independence"
- "intense"
+ - "love"
+ - "kwanzaa"
- "mellow"
+ - "party"
- "peaceful"
- "powerful"
+ - "proud"
+ - "pumpkin"
- "relaxing"
+ - "romance"
- "santa"
- "serene"
+ - "shamrock"
- "soothing"
+ - "spacey"
- "sports"
- "spring"
+ - "stardust"
+ - "thanksgiving"
- "tranquil"
- "warming"
+ - "zombie"
power_on:
default: true
selector:
@@ -338,3 +374,73 @@ effect_stop:
entity:
integration: lifx
domain: light
+paint_theme:
+ target:
+ entity:
+ integration: lifx
+ domain: light
+ fields:
+ palette:
+ example:
+ - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]"
+ selector:
+ object:
+ theme:
+ example: exciting
+ default: exciting
+ selector:
+ select:
+ mode: dropdown
+ options:
+ - "autumn"
+ - "blissful"
+ - "bias_lighting"
+ - "calaveras"
+ - "cheerful"
+ - "christmas"
+ - "dream"
+ - "energizing"
+ - "epic"
+ - "evening"
+ - "exciting"
+ - "fantasy"
+ - "focusing"
+ - "gentle"
+ - "halloween"
+ - "hanukkah"
+ - "holly"
+ - "hygge"
+ - "independence"
+ - "intense"
+ - "love"
+ - "kwanzaa"
+ - "mellow"
+ - "party"
+ - "peaceful"
+ - "powerful"
+ - "proud"
+ - "pumpkin"
+ - "relaxing"
+ - "romance"
+ - "santa"
+ - "serene"
+ - "shamrock"
+ - "soothing"
+ - "spacey"
+ - "sports"
+ - "spring"
+ - "stardust"
+ - "thanksgiving"
+ - "tranquil"
+ - "warming"
+ - "zombie"
+ transition:
+ selector:
+ number:
+ min: 0
+ max: 3600
+ unit_of_measurement: seconds
+ power_on:
+ default: true
+ selector:
+ boolean:
diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json
index 19d86e57f09..39102d904d5 100644
--- a/homeassistant/components/lifx/strings.json
+++ b/homeassistant/components/lifx/strings.json
@@ -209,7 +209,7 @@
},
"palette": {
"name": "Palette",
- "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute."
+ "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
@@ -254,6 +254,28 @@
"effect_stop": {
"name": "Stop effect",
"description": "Stops a running effect."
+ },
+ "paint_theme": {
+ "name": "Paint Theme",
+ "description": "Paint either a provided theme or custom palette across one or more LIFX lights.",
+ "fields": {
+ "palette": {
+ "name": "Palette",
+ "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute."
+ },
+ "theme": {
+ "name": "[%key:component::lifx::entity::select::theme::name%]",
+ "description": "Predefined color theme to paint. Overridden by the palette attribute."
+ },
+ "transition": {
+ "name": "Transition",
+ "description": "Duration in seconds to paint the theme."
+ },
+ "power_on": {
+ "name": "Power on",
+ "description": "Powered off lights will be turned on before painting the theme."
+ }
+ }
}
}
}
diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py
index ffffe7a4856..8286622e6f3 100644
--- a/homeassistant/components/lifx/util.py
+++ b/homeassistant/components/lifx/util.py
@@ -24,7 +24,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import (
_ATTR_COLOR_TEMP,
@@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
saturation = int(saturation / 100 * 65535)
kelvin = 3500
- if _ATTR_COLOR_TEMP in kwargs:
+ if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs:
# added in 2025.1, can be removed in 2026.1
_LOGGER.warning(
"The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for"
diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py
index b40cb081ed7..f6ba01dbdae 100644
--- a/homeassistant/components/lifx_cloud/scene.py
+++ b/homeassistant/components/lifx_cloud/scene.py
@@ -14,8 +14,8 @@ import voluptuous as vol
from homeassistant.components.scene import Scene
from homeassistant.const import CONF_PLATFORM, CONF_TIMEOUT, CONF_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py
index 76fbea70322..d87dcf41161 100644
--- a/homeassistant/components/light/__init__.py
+++ b/homeassistant/components/light/__init__.py
@@ -10,7 +10,7 @@ import logging
import os
from typing import Any, Final, Self, cast, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -35,7 +35,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.loader import bind_hass
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import ( # noqa: F401
COLOR_MODES_BRIGHTNESS,
@@ -1388,7 +1388,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
- if type(features) is not int: # noqa: E721
+ if type(features) is not int:
return features
new_features = LightEntityFeature(features)
if self._deprecated_supported_features_reported is True:
diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py
index e496255029a..83f2ee58b5e 100644
--- a/homeassistant/components/light/intent.py
+++ b/homeassistant/components/light/intent.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.const import SERVICE_TURN_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, intent
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR
from .const import DOMAIN
diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py
index 6c462b040d4..ef2a69c9f4f 100644
--- a/homeassistant/components/lightwave/__init__.py
+++ b/homeassistant/components/lightwave/__init__.py
@@ -13,7 +13,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py
index 4b2b75be9d7..22e2071b6a7 100644
--- a/homeassistant/components/limitlessled/light.py
+++ b/homeassistant/components/limitlessled/light.py
@@ -34,7 +34,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py
index 7dfdce238ff..11e4aabf257 100644
--- a/homeassistant/components/linkplay/config_flow.py
+++ b/homeassistant/components/linkplay/config_flow.py
@@ -9,9 +9,9 @@ from linkplay.discovery import linkplay_factory_httpapi_bridge
from linkplay.exceptions import LinkPlayRequestException
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .utils import async_get_client_session
@@ -27,7 +27,7 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
self.data: dict[str, Any] = {}
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle Zeroconf discovery."""
diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json
index cc124ceb611..ec9a8759a30 100644
--- a/homeassistant/components/linkplay/manifest.json
+++ b/homeassistant/components/linkplay/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["linkplay"],
- "requirements": ["python-linkplay==0.1.1"],
+ "requirements": ["python-linkplay==0.1.3"],
"zeroconf": ["_linkplay._tcp.local."]
}
diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py
index 596b7012140..c3b0b666d50 100644
--- a/homeassistant/components/linksys_smart/device_tracker.py
+++ b/homeassistant/components/linksys_smart/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DEFAULT_TIMEOUT = 10
diff --git a/homeassistant/components/linode/__init__.py b/homeassistant/components/linode/__init__.py
index 80c082344e7..d59c849f8f0 100644
--- a/homeassistant/components/linode/__init__.py
+++ b/homeassistant/components/linode/__init__.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py
index d0c49c7171b..93bdef4a1f4 100644
--- a/homeassistant/components/linode/binary_sensor.py
+++ b/homeassistant/components/linode/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py
index abaf77648ef..74d2099a844 100644
--- a/homeassistant/components/linode/switch.py
+++ b/homeassistant/components/linode/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
SwitchEntity,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py
index 789195e1169..fffb6357a28 100644
--- a/homeassistant/components/linux_battery/sensor.py
+++ b/homeassistant/components/linux_battery/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py
index 9aa0b19c506..aeae8f52144 100644
--- a/homeassistant/components/litejet/config_flow.py
+++ b/homeassistant/components/litejet/config_flow.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_PORT
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import CONF_DEFAULT_TRANSITION, DOMAIN
diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py
index 2786cc8b76a..a35bf6fb65e 100644
--- a/homeassistant/components/litejet/trigger.py
+++ b/homeassistant/components/litejet/trigger.py
@@ -11,11 +11,11 @@ import voluptuous as vol
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py
index 3c55c4c4035..1f926d37a61 100644
--- a/homeassistant/components/litterrobot/__init__.py
+++ b/homeassistant/components/litterrobot/__init__.py
@@ -2,51 +2,31 @@
from __future__ import annotations
-from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
-
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
-from .hub import LitterRobotHub
+from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
-type LitterRobotConfigEntry = ConfigEntry[LitterRobotHub]
-
-PLATFORMS_BY_TYPE = {
- Robot: (
- Platform.BINARY_SENSOR,
- Platform.SELECT,
- Platform.SENSOR,
- Platform.SWITCH,
- ),
- LitterRobot: (Platform.VACUUM,),
- LitterRobot3: (Platform.BUTTON, Platform.TIME),
- LitterRobot4: (Platform.UPDATE,),
- FeederRobot: (Platform.BUTTON,),
-}
-
-
-def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]:
- """Get platforms for robots."""
- return {
- platform
- for robot in robots
- for robot_type, platforms in PLATFORMS_BY_TYPE.items()
- if isinstance(robot, robot_type)
- for platform in platforms
- }
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.TIME,
+ Platform.UPDATE,
+ Platform.VACUUM,
+]
async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool:
"""Set up Litter-Robot from a config entry."""
- hub = LitterRobotHub(hass, entry.data)
- await hub.login(load_robots=True, subscribe_for_updates=True)
- entry.runtime_data = hub
-
- if platforms := get_platforms_for_robots(hub.account.robots):
- await hass.config_entries.async_forward_entry_setups(entry, platforms)
+ coordinator = LitterRobotDataUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -55,9 +35,7 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
await entry.runtime_data.account.disconnect()
-
- platforms = get_platforms_for_robots(entry.runtime_data.account.robots)
- return await hass.config_entries.async_unload_platforms(entry, platforms)
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_config_entry_device(
diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py
index 91113d6c094..e6cf23fa27c 100644
--- a/homeassistant/components/litterrobot/binary_sensor.py
+++ b/homeassistant/components/litterrobot/binary_sensor.py
@@ -17,33 +17,17 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _RobotT
-@dataclass(frozen=True)
-class RequiredKeysMixin(Generic[_RobotT]):
- """A class that describes robot binary sensor entity required keys."""
-
- is_on_fn: Callable[[_RobotT], bool]
-
-
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class RobotBinarySensorEntityDescription(
- BinarySensorEntityDescription, RequiredKeysMixin[_RobotT]
+ BinarySensorEntityDescription, Generic[_RobotT]
):
"""A class that describes robot binary sensor entities."""
-
-class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity):
- """Litter-Robot binary sensor entity."""
-
- entity_description: RobotBinarySensorEntityDescription[_RobotT]
-
- @property
- def is_on(self) -> bool:
- """Return the state."""
- return self.entity_description.is_on_fn(self.robot)
+ is_on_fn: Callable[[_RobotT], bool]
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
@@ -82,11 +66,24 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot binary sensors using config entry."""
- hub = entry.runtime_data
+ coordinator = entry.runtime_data
async_add_entities(
- LitterRobotBinarySensorEntity(robot=robot, hub=hub, description=description)
- for robot in hub.account.robots
+ LitterRobotBinarySensorEntity(
+ robot=robot, coordinator=coordinator, description=description
+ )
+ for robot in coordinator.account.robots
for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
+
+
+class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity):
+ """Litter-Robot binary sensor entity."""
+
+ entity_description: RobotBinarySensorEntityDescription[_RobotT]
+
+ @property
+ def is_on(self) -> bool:
+ """Return the state."""
+ return self.entity_description.is_on_fn(self.robot)
diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py
index 6e6cc563c8e..01888e7fbae 100644
--- a/homeassistant/components/litterrobot/button.py
+++ b/homeassistant/components/litterrobot/button.py
@@ -4,70 +4,62 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
-import itertools
from typing import Any, Generic
-from pylitterbot import FeederRobot, LitterRobot3
+from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, Robot
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _RobotT
+@dataclass(frozen=True, kw_only=True)
+class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]):
+ """A class that describes robot button entities."""
+
+ press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]]
+
+
+ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = {
+ LitterRobot3: RobotButtonEntityDescription[LitterRobot3](
+ key="reset_waste_drawer",
+ translation_key="reset_waste_drawer",
+ entity_category=EntityCategory.CONFIG,
+ press_fn=lambda robot: robot.reset_waste_drawer(),
+ ),
+ LitterRobot4: RobotButtonEntityDescription[LitterRobot4](
+ key="reset",
+ translation_key="reset",
+ entity_category=EntityCategory.CONFIG,
+ press_fn=lambda robot: robot.reset(),
+ ),
+ FeederRobot: RobotButtonEntityDescription[FeederRobot](
+ key="give_snack",
+ translation_key="give_snack",
+ press_fn=lambda robot: robot.give_snack(),
+ ),
+}
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
- hub = entry.runtime_data
- entities: list[LitterRobotButtonEntity] = list(
- itertools.chain(
- (
- LitterRobotButtonEntity(
- robot=robot, hub=hub, description=LITTER_ROBOT_BUTTON
- )
- for robot in hub.litter_robots()
- if isinstance(robot, LitterRobot3)
- ),
- (
- LitterRobotButtonEntity(
- robot=robot, hub=hub, description=FEEDER_ROBOT_BUTTON
- )
- for robot in hub.feeder_robots()
- ),
+ coordinator = entry.runtime_data
+ async_add_entities(
+ LitterRobotButtonEntity(
+ robot=robot, coordinator=coordinator, description=description
)
+ for robot in coordinator.account.robots
+ for robot_type, description in ROBOT_BUTTON_MAP.items()
+ if isinstance(robot, robot_type)
)
- async_add_entities(entities)
-
-
-@dataclass(frozen=True)
-class RequiredKeysMixin(Generic[_RobotT]):
- """A class that describes robot button entity required keys."""
-
- press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]]
-
-
-@dataclass(frozen=True)
-class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_RobotT]):
- """A class that describes robot button entities."""
-
-
-LITTER_ROBOT_BUTTON = RobotButtonEntityDescription[LitterRobot3](
- key="reset_waste_drawer",
- translation_key="reset_waste_drawer",
- entity_category=EntityCategory.CONFIG,
- press_fn=lambda robot: robot.reset_waste_drawer(),
-)
-FEEDER_ROBOT_BUTTON = RobotButtonEntityDescription[FeederRobot](
- key="give_snack",
- translation_key="give_snack",
- press_fn=lambda robot: robot.give_snack(),
-)
class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity):
@@ -78,4 +70,4 @@ class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.robot)
- self.coordinator.async_set_updated_data(True)
+ self.coordinator.async_set_updated_data(None)
diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/coordinator.py
similarity index 51%
rename from homeassistant/components/litterrobot/hub.py
rename to homeassistant/components/litterrobot/coordinator.py
index 77050855c70..a56a6607d32 100644
--- a/homeassistant/components/litterrobot/hub.py
+++ b/homeassistant/components/litterrobot/coordinator.py
@@ -1,64 +1,66 @@
-"""A wrapper 'hub' for the Litter-Robot API."""
+"""The Litter-Robot coordinator."""
from __future__ import annotations
-from collections.abc import Generator, Mapping
+from collections.abc import Generator
from datetime import timedelta
import logging
-from typing import Any
from pylitterbot import Account, FeederRobot, LitterRobot
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-UPDATE_INTERVAL_SECONDS = 60 * 5
+UPDATE_INTERVAL = timedelta(minutes=5)
+
+type LitterRobotConfigEntry = ConfigEntry[LitterRobotDataUpdateCoordinator]
-class LitterRobotHub:
- """A Litter-Robot hub wrapper class."""
+class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
+ """The Litter-Robot data update coordinator."""
- def __init__(self, hass: HomeAssistant, data: Mapping[str, Any]) -> None:
- """Initialize the Litter-Robot hub."""
- self._data = data
- self.account = Account(websession=async_get_clientsession(hass))
+ config_entry: LitterRobotConfigEntry
- async def _async_update_data() -> bool:
- """Update all device states from the Litter-Robot API."""
- await self.account.refresh_robots()
- return True
-
- self.coordinator = DataUpdateCoordinator(
+ def __init__(
+ self, hass: HomeAssistant, config_entry: LitterRobotConfigEntry
+ ) -> None:
+ """Initialize the Litter-Robot data update coordinator."""
+ super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
- update_method=_async_update_data,
- update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS),
+ update_interval=UPDATE_INTERVAL,
)
- async def login(
- self, load_robots: bool = False, subscribe_for_updates: bool = False
- ) -> None:
- """Login to Litter-Robot."""
+ self.account = Account(websession=async_get_clientsession(hass))
+
+ async def _async_update_data(self) -> None:
+ """Update all device states from the Litter-Robot API."""
+ await self.account.refresh_robots()
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
try:
await self.account.connect(
- username=self._data[CONF_USERNAME],
- password=self._data[CONF_PASSWORD],
- load_robots=load_robots,
- subscribe_for_updates=subscribe_for_updates,
+ username=self.config_entry.data[CONF_USERNAME],
+ password=self.config_entry.data[CONF_PASSWORD],
+ load_robots=True,
+ subscribe_for_updates=True,
)
except LitterRobotLoginException as ex:
raise ConfigEntryAuthFailed("Invalid credentials") from ex
except LitterRobotException as ex:
- raise ConfigEntryNotReady("Unable to connect to Litter-Robot API") from ex
+ raise UpdateFailed("Unable to connect to Litter-Robot API") from ex
def litter_robots(self) -> Generator[LitterRobot]:
"""Get Litter-Robots from the account."""
diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py
index 4639404b92b..36cbbb730ce 100644
--- a/homeassistant/components/litterrobot/entity.py
+++ b/homeassistant/components/litterrobot/entity.py
@@ -9,44 +9,39 @@ from pylitterbot.robot import EVENT_UPDATE
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .hub import LitterRobotHub
+from .coordinator import LitterRobotDataUpdateCoordinator
_RobotT = TypeVar("_RobotT", bound=Robot)
class LitterRobotEntity(
- CoordinatorEntity[DataUpdateCoordinator[bool]], Generic[_RobotT]
+ CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT]
):
"""Generic Litter-Robot entity representing common data and methods."""
_attr_has_entity_name = True
def __init__(
- self, robot: _RobotT, hub: LitterRobotHub, description: EntityDescription
+ self,
+ robot: _RobotT,
+ coordinator: LitterRobotDataUpdateCoordinator,
+ description: EntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
- super().__init__(hub.coordinator)
+ super().__init__(coordinator)
self.robot = robot
- self.hub = hub
self.entity_description = description
- self._attr_unique_id = f"{self.robot.serial}-{description.key}"
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return the device information for a Litter-Robot."""
- assert self.robot.serial
- return DeviceInfo(
- identifiers={(DOMAIN, self.robot.serial)},
- manufacturer="Litter-Robot",
- model=self.robot.model,
- name=self.robot.name,
- sw_version=getattr(self.robot, "firmware", None),
+ self._attr_unique_id = f"{robot.serial}-{description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, robot.serial)},
+ manufacturer="Whisker",
+ model=robot.model,
+ name=robot.name,
+ serial_number=robot.serial,
+ sw_version=getattr(robot, "firmware", None),
)
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json
index 482031f8424..ba3df2114b7 100644
--- a/homeassistant/components/litterrobot/icons.json
+++ b/homeassistant/components/litterrobot/icons.json
@@ -17,6 +17,14 @@
}
},
"select": {
+ "brightness_level": {
+ "default": "mdi:lightbulb-question",
+ "state": {
+ "low": "mdi:lightbulb-on-30",
+ "medium": "mdi:lightbulb-on-50",
+ "high": "mdi:lightbulb-on"
+ }
+ },
"cycle_delay": {
"default": "mdi:timer-outline"
},
diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json
index 88396f9f9c1..f7563296711 100644
--- a/homeassistant/components/litterrobot/manifest.json
+++ b/homeassistant/components/litterrobot/manifest.json
@@ -12,5 +12,6 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
- "requirements": ["pylitterbot==2023.5.0"]
+ "quality_scale": "bronze",
+ "requirements": ["pylitterbot==2024.0.0"]
}
diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml
new file mode 100644
index 00000000000..82f01f64d18
--- /dev/null
+++ b/homeassistant/components/litterrobot/quality_scale.yaml
@@ -0,0 +1,82 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling:
+ status: done
+ comment: |
+ Primarily relies on push data, but polls every 5 minutes for missed updates
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: done
+ comment: No options to configure
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: |
+ Move big data objects from common.py into JSON fixtures and oad them when needed.
+ Other fields can be moved to const.py. Consider snapshots and testing data updates
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: done
+ comment: The integration is cloud-based
+ discovery:
+ status: todo
+ comment: Need to validate discovery
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations:
+ status: todo
+ comment: Make sure all translated states are in sentence case
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: done
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed
+ stale-devices:
+ status: todo
+ comment: |
+ Currently handled via async_remove_config_entry_device,
+ but we should be able to remove devices automatically
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py
index 948fad45a76..1a3d2fc2fb4 100644
--- a/homeassistant/components/litterrobot/select.py
+++ b/homeassistant/components/litterrobot/select.py
@@ -14,37 +14,22 @@ from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
from .entity import LitterRobotEntity, _RobotT
-from .hub import LitterRobotHub
_CastTypeT = TypeVar("_CastTypeT", int, float, str)
-BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = {
- BrightnessLevel.LOW: "mdi:lightbulb-on-30",
- BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50",
- BrightnessLevel.HIGH: "mdi:lightbulb-on",
- None: "mdi:lightbulb-question",
-}
-
-@dataclass(frozen=True)
-class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]):
- """A class that describes robot select entity required keys."""
-
- current_fn: Callable[[_RobotT], _CastTypeT | None]
- options_fn: Callable[[_RobotT], list[_CastTypeT]]
- select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]]
-
-
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class RobotSelectEntityDescription(
- SelectEntityDescription, RequiredKeysMixin[_RobotT, _CastTypeT]
+ SelectEntityDescription, Generic[_RobotT, _CastTypeT]
):
"""A class that describes robot select entities."""
entity_category: EntityCategory = EntityCategory.CONFIG
- icon_fn: Callable[[_RobotT], str] | None = None
+ current_fn: Callable[[_RobotT], _CastTypeT | None]
+ options_fn: Callable[[_RobotT], list[_CastTypeT]]
+ select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]]
ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
@@ -59,14 +44,15 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str](
key="panel_brightness",
translation_key="brightness_level",
- current_fn=lambda robot: bri.name.lower()
- if (bri := robot.panel_brightness) is not None
- else None,
- options_fn=lambda _: [level.name.lower() for level in BrightnessLevel],
- select_fn=lambda robot, opt: robot.set_panel_brightness(
- BrightnessLevel[opt.upper()]
+ current_fn=(
+ lambda robot: bri.name.lower()
+ if (bri := robot.panel_brightness) is not None
+ else None
+ ),
+ options_fn=lambda _: [level.name.lower() for level in BrightnessLevel],
+ select_fn=(
+ lambda robot, opt: robot.set_panel_brightness(BrightnessLevel[opt.upper()])
),
- icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness],
),
FeederRobot: RobotSelectEntityDescription[FeederRobot, float](
key="meal_insert_size",
@@ -85,14 +71,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot selects using config entry."""
- hub = entry.runtime_data
- entities = [
- LitterRobotSelectEntity(robot=robot, hub=hub, description=description)
- for robot in hub.account.robots
+ coordinator = entry.runtime_data
+ async_add_entities(
+ LitterRobotSelectEntity(
+ robot=robot, coordinator=coordinator, description=description
+ )
+ for robot in coordinator.account.robots
for robot_type, description in ROBOT_SELECT_MAP.items()
if isinstance(robot, robot_type)
- ]
- async_add_entities(entities)
+ )
class LitterRobotSelectEntity(
@@ -105,21 +92,14 @@ class LitterRobotSelectEntity(
def __init__(
self,
robot: _RobotT,
- hub: LitterRobotHub,
+ coordinator: LitterRobotDataUpdateCoordinator,
description: RobotSelectEntityDescription[_RobotT, _CastTypeT],
) -> None:
"""Initialize a Litter-Robot select entity."""
- super().__init__(robot, hub, description)
+ super().__init__(robot, coordinator, description)
options = self.entity_description.options_fn(self.robot)
self._attr_options = list(map(str, options))
- @property
- def icon(self) -> str | None:
- """Return the icon to use in the frontend, if any."""
- if icon_fn := self.entity_description.icon_fn:
- return str(icon_fn(self.robot))
- return super().icon
-
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py
index c110b89c7da..6545d7c7ae7 100644
--- a/homeassistant/components/litterrobot/sensor.py
+++ b/homeassistant/components/litterrobot/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
-from typing import Any, Generic, cast
+from typing import Any, Generic
from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot
@@ -19,7 +19,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _RobotT
@@ -34,34 +34,12 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str
return "mdi:gauge-low"
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]):
"""A class that describes robot sensor entities."""
icon_fn: Callable[[Any], str | None] = lambda _: None
- should_report: Callable[[_RobotT], bool] = lambda _: True
-
-
-class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity):
- """Litter-Robot sensor entity."""
-
- entity_description: RobotSensorEntityDescription[_RobotT]
-
- @property
- def native_value(self) -> float | datetime | str | None:
- """Return the state."""
- if self.entity_description.should_report(self.robot):
- if isinstance(val := getattr(self.robot, self.entity_description.key), str):
- return val.lower()
- return cast(float | datetime | None, val)
- return None
-
- @property
- def icon(self) -> str | None:
- """Return the icon to use in the frontend, if any."""
- if (icon := self.entity_description.icon_fn(self.state)) is not None:
- return icon
- return super().icon
+ value_fn: Callable[[_RobotT], float | datetime | str | None]
ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
@@ -72,24 +50,34 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
native_unit_of_measurement=PERCENTAGE,
icon_fn=lambda state: icon_for_gauge_level(state, 10),
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda robot: robot.waste_drawer_level,
),
RobotSensorEntityDescription[LitterRobot](
key="sleep_mode_start_time",
translation_key="sleep_mode_start_time",
device_class=SensorDeviceClass.TIMESTAMP,
- should_report=lambda robot: robot.sleep_mode_enabled,
+ value_fn=(
+ lambda robot: robot.sleep_mode_start_time
+ if robot.sleep_mode_enabled
+ else None
+ ),
),
RobotSensorEntityDescription[LitterRobot](
key="sleep_mode_end_time",
translation_key="sleep_mode_end_time",
device_class=SensorDeviceClass.TIMESTAMP,
- should_report=lambda robot: robot.sleep_mode_enabled,
+ value_fn=(
+ lambda robot: robot.sleep_mode_end_time
+ if robot.sleep_mode_enabled
+ else None
+ ),
),
RobotSensorEntityDescription[LitterRobot](
key="last_seen",
translation_key="last_seen",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda robot: robot.last_seen,
),
RobotSensorEntityDescription[LitterRobot](
key="status_code",
@@ -123,6 +111,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
"sdf",
"spf",
],
+ value_fn=(
+ lambda robot: status.lower() if (status := robot.status_code) else None
+ ),
),
],
LitterRobot4: [
@@ -132,6 +123,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
native_unit_of_measurement=PERCENTAGE,
icon_fn=lambda state: icon_for_gauge_level(state, 10),
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda robot: robot.litter_level,
),
RobotSensorEntityDescription[LitterRobot4](
key="pet_weight",
@@ -139,6 +131,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
native_unit_of_measurement=UnitOfMass.POUNDS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda robot: robot.pet_weight,
),
],
FeederRobot: [
@@ -148,6 +141,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
native_unit_of_measurement=PERCENTAGE,
icon_fn=lambda state: icon_for_gauge_level(state, 10),
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda robot: robot.food_level,
)
],
}
@@ -159,12 +153,31 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot sensors using config entry."""
- hub = entry.runtime_data
- entities = [
- LitterRobotSensorEntity(robot=robot, hub=hub, description=description)
- for robot in hub.account.robots
+ coordinator = entry.runtime_data
+ async_add_entities(
+ LitterRobotSensorEntity(
+ robot=robot, coordinator=coordinator, description=description
+ )
+ for robot in coordinator.account.robots
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
- ]
- async_add_entities(entities)
+ )
+
+
+class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity):
+ """Litter-Robot sensor entity."""
+
+ entity_description: RobotSensorEntityDescription[_RobotT]
+
+ @property
+ def native_value(self) -> float | datetime | str | None:
+ """Return the state."""
+ return self.entity_description.value_fn(self.robot)
+
+ @property
+ def icon(self) -> str | None:
+ """Return the icon to use in the frontend, if any."""
+ if (icon := self.entity_description.icon_fn(self.state)) is not None:
+ return icon
+ return super().icon
diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json
index 7acfad69735..19b007de068 100644
--- a/homeassistant/components/litterrobot/strings.json
+++ b/homeassistant/components/litterrobot/strings.json
@@ -5,6 +5,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The email address of your Whisker account.",
+ "password": "The password of your Whisker account."
}
},
"reauth_confirm": {
@@ -12,6 +16,9 @@
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::litterrobot::config::step::user::data_description::password%]"
}
}
},
@@ -38,6 +45,9 @@
}
},
"button": {
+ "reset": {
+ "name": "Reset"
+ },
"reset_waste_drawer": {
"name": "Reset waste drawer"
},
diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py
index 133fd897cc6..7ded89d552b 100644
--- a/homeassistant/components/litterrobot/switch.py
+++ b/homeassistant/components/litterrobot/switch.py
@@ -13,22 +13,17 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _RobotT
-@dataclass(frozen=True)
-class RequiredKeysMixin(Generic[_RobotT]):
- """A class that describes robot switch entity required keys."""
-
- set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]]
-
-
-@dataclass(frozen=True)
-class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_RobotT]):
+@dataclass(frozen=True, kw_only=True)
+class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]):
"""A class that describes robot switch entities."""
entity_category: EntityCategory = EntityCategory.CONFIG
+ set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]]
+ value_fn: Callable[[_RobotT], bool]
ROBOT_SWITCHES = [
@@ -36,15 +31,32 @@ ROBOT_SWITCHES = [
key="night_light_mode_enabled",
translation_key="night_light_mode",
set_fn=lambda robot, value: robot.set_night_light(value),
+ value_fn=lambda robot: robot.night_light_mode_enabled,
),
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
key="panel_lock_enabled",
translation_key="panel_lockout",
set_fn=lambda robot, value: robot.set_panel_lockout(value),
+ value_fn=lambda robot: robot.panel_lock_enabled,
),
]
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LitterRobotConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Litter-Robot switches using config entry."""
+ coordinator = entry.runtime_data
+ async_add_entities(
+ RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
+ for description in ROBOT_SWITCHES
+ for robot in coordinator.account.robots
+ if isinstance(robot, (LitterRobot, FeederRobot))
+ )
+
+
class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity):
"""Litter-Robot switch entity."""
@@ -53,7 +65,7 @@ class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity):
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
- return bool(getattr(self.robot, self.entity_description.key))
+ return self.entity_description.value_fn(self.robot)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
@@ -62,19 +74,3 @@ class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.robot, False)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Litter-Robot switches using config entry."""
- hub = entry.runtime_data
- entities = [
- RobotSwitchEntity(robot=robot, hub=hub, description=description)
- for description in ROBOT_SWITCHES
- for robot in hub.account.robots
- if isinstance(robot, (LitterRobot, FeederRobot))
- ]
- async_add_entities(entities)
diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py
index ace30d9f3a9..3fa93b14dd9 100644
--- a/homeassistant/components/litterrobot/time.py
+++ b/homeassistant/components/litterrobot/time.py
@@ -13,25 +13,20 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _RobotT
-@dataclass(frozen=True)
-class RequiredKeysMixin(Generic[_RobotT]):
- """A class that describes robot time entity required keys."""
+@dataclass(frozen=True, kw_only=True)
+class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]):
+ """A class that describes robot time entities."""
value_fn: Callable[[_RobotT], time | None]
set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]]
-@dataclass(frozen=True)
-class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]):
- """A class that describes robot time entities."""
-
-
def _as_local_time(start: datetime | None) -> time | None:
"""Return a datetime as local time."""
return dt_util.as_local(start).time() if start else None
@@ -42,8 +37,11 @@ LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3](
translation_key="sleep_mode_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time),
- set_fn=lambda robot, value: robot.set_sleep_mode(
- robot.sleep_mode_enabled, value.replace(tzinfo=dt_util.get_default_time_zone())
+ set_fn=(
+ lambda robot, value: robot.set_sleep_mode(
+ robot.sleep_mode_enabled,
+ value.replace(tzinfo=dt_util.get_default_time_zone()),
+ )
),
)
@@ -54,15 +52,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
- hub = entry.runtime_data
+ coordinator = entry.runtime_data
async_add_entities(
- [
- LitterRobotTimeEntity(
- robot=robot, hub=hub, description=LITTER_ROBOT_3_SLEEP_START
- )
- for robot in hub.litter_robots()
- if isinstance(robot, LitterRobot3)
- ]
+ LitterRobotTimeEntity(
+ robot=robot,
+ coordinator=coordinator,
+ description=LITTER_ROBOT_3_SLEEP_START,
+ )
+ for robot in coordinator.litter_robots()
+ if isinstance(robot, LitterRobot3)
)
diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py
index 1d3e1dff57c..53ab23e9db8 100644
--- a/homeassistant/components/litterrobot/update.py
+++ b/homeassistant/components/litterrobot/update.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
SCAN_INTERVAL = timedelta(days=1)
@@ -34,12 +34,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot update platform."""
- hub = entry.runtime_data
- entities = [
- RobotUpdateEntity(robot=robot, hub=hub, description=FIRMWARE_UPDATE_ENTITY)
- for robot in hub.litter_robots()
+ coordinator = entry.runtime_data
+ entities = (
+ RobotUpdateEntity(
+ robot=robot, coordinator=coordinator, description=FIRMWARE_UPDATE_ENTITY
+ )
+ for robot in coordinator.litter_robots()
if isinstance(robot, LitterRobot4)
- ]
+ )
async_add_entities(entities, True)
diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py
index bd00c328233..314fab6a621 100644
--- a/homeassistant/components/litterrobot/vacuum.py
+++ b/homeassistant/components/litterrobot/vacuum.py
@@ -18,9 +18,9 @@ from homeassistant.components.vacuum import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
-from . import LitterRobotConfigEntry
+from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
SERVICE_SET_SLEEP_MODE = "set_sleep_mode"
@@ -49,12 +49,13 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
- hub = entry.runtime_data
- entities = [
- LitterRobotCleaner(robot=robot, hub=hub, description=LITTER_BOX_ENTITY)
- for robot in hub.litter_robots()
- ]
- async_add_entities(entities)
+ coordinator = entry.runtime_data
+ async_add_entities(
+ LitterRobotCleaner(
+ robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY
+ )
+ for robot in coordinator.litter_robots()
+ )
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -79,13 +80,6 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):
"""Return the state of the cleaner."""
return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR)
- @property
- def status(self) -> str:
- """Return the status of the cleaner."""
- return (
- f"{self.robot.status.text}{' (Sleeping)' if self.robot.is_sleeping else ''}"
- )
-
async def async_start(self) -> None:
"""Start a clean cycle."""
await self.robot.set_power_status(True)
@@ -121,13 +115,3 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):
)
.timetz()
)
-
- @property
- def extra_state_attributes(self) -> dict[str, Any]:
- """Return device specific state attributes."""
- return {
- "is_sleeping": self.robot.is_sleeping,
- "sleep_mode_enabled": self.robot.sleep_mode_enabled,
- "power_status": self.robot.power_status,
- "status": self.status,
- }
diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json
index 27798d0456c..21a4134a8b6 100644
--- a/homeassistant/components/local_calendar/manifest.json
+++ b/homeassistant/components/local_calendar/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
- "requirements": ["ical==8.2.0"]
+ "requirements": ["ical==8.3.0"]
}
diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json
index c126799c39d..68154f10885 100644
--- a/homeassistant/components/local_todo/manifest.json
+++ b/homeassistant/components/local_todo/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
- "requirements": ["ical==8.2.0"]
+ "requirements": ["ical==8.3.0"]
}
diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py
index ff2c2c4c3a3..4154f343f42 100644
--- a/homeassistant/components/locative/__init__.py
+++ b/homeassistant/components/locative/__init__.py
@@ -19,8 +19,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py
index 39d5d3c350d..05aed8a827f 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -9,7 +9,7 @@ import logging
import re
from typing import TYPE_CHECKING, Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -29,7 +29,7 @@ from homeassistant.const import ( # noqa: F401
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import (
all_with_deprecated_constants,
check_if_deprecated_constant,
diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py
index a75966414f8..a396849f049 100644
--- a/homeassistant/components/lock/device_action.py
+++ b/homeassistant/components/lock/device_action.py
@@ -16,8 +16,7 @@ from homeassistant.const import (
SERVICE_UNLOCK,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py
index c33325d7dcb..40b904c1279 100644
--- a/homeassistant/components/logbook/models.py
+++ b/homeassistant/components/logbook/models.py
@@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast
-from propcache import cached_property
+from propcache.api import cached_property
from sqlalchemy.engine.row import Row
from homeassistant.components.recorder.filters import Filters
diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py
index 77aa71740f1..1a139bb379e 100644
--- a/homeassistant/components/logbook/processor.py
+++ b/homeassistant/components/logbook/processor.py
@@ -36,7 +36,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import entity_registry as er
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.event_type import EventType
from .const import (
@@ -115,9 +115,9 @@ class EventProcessor:
include_entity_name: bool = True,
) -> None:
"""Init the event stream."""
- assert not (
- context_id and (entity_ids or device_ids)
- ), "can't pass in both context_id and (entity_ids or device_ids)"
+ assert not (context_id and (entity_ids or device_ids)), (
+ "can't pass in both context_id and (entity_ids or device_ids)"
+ )
self.hass = hass
self.ent_reg = er.async_get(hass)
self.event_types = event_types
diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py
index c7ba196275b..e4a8e64cecf 100644
--- a/homeassistant/components/logbook/rest_api.py
+++ b/homeassistant/components/logbook/rest_api.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import InvalidEntityFormatError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .helpers import async_determine_event_types
from .processor import EventProcessor
diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py
index b295b845532..e3d0d8a29fa 100644
--- a/homeassistant/components/logbook/websocket_api.py
+++ b/homeassistant/components/logbook/websocket_api.py
@@ -17,8 +17,8 @@ from homeassistant.components.websocket_api import ActiveConnection, messages
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.json import json_bytes
+from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import create_eager_task
-import homeassistant.util.dt as dt_util
from .const import DOMAIN
from .helpers import (
diff --git a/homeassistant/components/logentries/__init__.py b/homeassistant/components/logentries/__init__.py
index 8ddf4a1a543..68d6af7e7dd 100644
--- a/homeassistant/components/logentries/__init__.py
+++ b/homeassistant/components/logentries/__init__.py
@@ -8,8 +8,7 @@ import voluptuous as vol
from homeassistant.const import CONF_TOKEN, EVENT_STATE_CHANGED
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py
index be6e8c1b24e..15283b246b2 100644
--- a/homeassistant/components/logger/__init__.py
+++ b/homeassistant/components/logger/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py
index 16dbfa5b871..81133433d05 100644
--- a/homeassistant/components/london_air/sensor.py
+++ b/homeassistant/components/london_air/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorEntity,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py
index 015f7e8ecdc..645f8f48ae2 100644
--- a/homeassistant/components/london_underground/sensor.py
+++ b/homeassistant/components/london_underground/sensor.py
@@ -14,8 +14,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py
index aaf98a06fa8..abf2982765d 100644
--- a/homeassistant/components/lookin/config_flow.py
+++ b/homeassistant/components/lookin/config_flow.py
@@ -9,10 +9,10 @@ import aiohttp
from aiolookin import Device, LookInHttpProtocol, NoUsableService
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -28,7 +28,7 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN):
self._name: str | None = None
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Start a discovery flow from zeroconf."""
uid: str = discovery_info.hostname.removesuffix(".local.")
diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py
index 8c82a7a6964..a3879d0412f 100644
--- a/homeassistant/components/loqed/config_flow.py
+++ b/homeassistant/components/loqed/config_flow.py
@@ -11,12 +11,12 @@ from loqedAPI import cloud_loqed, loqed
import voluptuous as vol
from homeassistant.components import webhook
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json
index e4cd4b71045..38eae7eb7b2 100644
--- a/homeassistant/components/loqed/strings.json
+++ b/homeassistant/components/loqed/strings.json
@@ -3,7 +3,7 @@
"flow_title": "LOQED Touch Smartlock setup",
"step": {
"user": {
- "description": "Login at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.",
+ "description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token.",
"data": {
"name": "Name of your lock in the LOQED app.",
"api_token": "[%key:common::config_flow::data::api_token%]"
diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py
index d26e4f1d2d7..4d8472da9a2 100644
--- a/homeassistant/components/lovelace/__init__.py
+++ b/homeassistant/components/lovelace/__init__.py
@@ -1,10 +1,12 @@
"""Support for the Lovelace UI."""
+from dataclasses import dataclass
import logging
+from typing import Any
import voluptuous as vol
-from homeassistant.components import frontend, onboarding, websocket_api
+from homeassistant.components import frontend, websocket_api
from homeassistant.config import (
async_hass_config_yaml,
async_process_component_and_handle_errors,
@@ -13,10 +15,11 @@ from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, config_validation as cv
+from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.service import async_register_admin_service
-from homeassistant.helpers.translation import async_get_translations
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
+from homeassistant.util import slugify
from . import dashboard, resources, websocket
from .const import ( # noqa: F401
@@ -30,6 +33,7 @@ from .const import ( # noqa: F401
DEFAULT_ICON,
DOMAIN,
EVENT_LOVELACE_UPDATED,
+ LOVELACE_DATA,
MODE_STORAGE,
MODE_YAML,
RESOURCE_CREATE_FIELDS,
@@ -39,12 +43,25 @@ from .const import ( # noqa: F401
SERVICE_RELOAD_RESOURCES,
STORAGE_DASHBOARD_CREATE_FIELDS,
STORAGE_DASHBOARD_UPDATE_FIELDS,
- url_slug,
)
from .system_health import system_health_info # noqa: F401
_LOGGER = logging.getLogger(__name__)
+
+def _validate_url_slug(value: Any) -> str:
+ """Validate value is a valid url slug."""
+ if value is None:
+ raise vol.Invalid("Slug should not be None")
+ if "-" not in value:
+ raise vol.Invalid("Url path needs to contain a hyphen (-)")
+ str_value = str(value)
+ slg = slugify(str_value, separator="-")
+ if str_value == slg:
+ return str_value
+ raise vol.Invalid(f"invalid slug {value} (try {slg})")
+
+
CONF_DASHBOARDS = "dashboards"
YAML_DASHBOARD_SCHEMA = vol.Schema(
@@ -64,7 +81,7 @@ CONFIG_SCHEMA = vol.Schema(
),
vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys(
YAML_DASHBOARD_SCHEMA,
- slug_validator=url_slug,
+ slug_validator=_validate_url_slug,
),
vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA],
}
@@ -74,6 +91,44 @@ CONFIG_SCHEMA = vol.Schema(
)
+@dataclass
+class LovelaceData:
+ """Dataclass to store information in hass.data."""
+
+ mode: str
+ dashboards: dict[str | None, dashboard.LovelaceConfig]
+ resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection
+ yaml_dashboards: dict[str | None, ConfigType]
+
+ def __getitem__(self, name: str) -> Any:
+ """Enable method for compatibility reason.
+
+ Following migration from an untyped dict to a dataclass in
+ https://github.com/home-assistant/core/pull/136313
+ """
+ report_usage(
+ f"accessed lovelace_data['{name}'] instead of lovelace_data.{name}",
+ breaks_in_ha_version="2026.2",
+ exclude_integrations={DOMAIN},
+ )
+ return getattr(self, name)
+
+ def get(self, name: str, default: Any = None) -> Any:
+ """Enable method for compatibility reason.
+
+ Following migration from an untyped dict to a dataclass in
+ https://github.com/home-assistant/core/pull/136313
+ """
+ report_usage(
+ f"accessed lovelace_data.get('{name}') instead of lovelace_data.{name}",
+ breaks_in_ha_version="2026.2",
+ exclude_integrations={DOMAIN},
+ )
+ if hasattr(self, name):
+ return getattr(self, name)
+ return default
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Lovelace commands."""
mode = config[DOMAIN][CONF_MODE]
@@ -101,9 +156,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
resource_collection = await create_yaml_resource_col(
hass, config[DOMAIN].get(CONF_RESOURCES)
)
- hass.data[DOMAIN]["resources"] = resource_collection
+ hass.data[LOVELACE_DATA].resources = resource_collection
default_config: dashboard.LovelaceConfig
+ resource_collection: (
+ resources.ResourceYAMLCollection | resources.ResourceStorageCollection
+ )
if mode == MODE_YAML:
default_config = dashboard.LovelaceYAML(hass, None, None)
resource_collection = await create_yaml_resource_col(hass, yaml_resources)
@@ -152,28 +210,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, websocket.websocket_lovelace_delete_config
)
- hass.data[DOMAIN] = {
+ hass.data[LOVELACE_DATA] = LovelaceData(
+ mode=mode,
# We store a dictionary mapping url_path: config. None is the default.
- "mode": mode,
- "dashboards": {None: default_config},
- "resources": resource_collection,
- "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}),
- }
+ dashboards={None: default_config},
+ resources=resource_collection,
+ yaml_dashboards=config[DOMAIN].get(CONF_DASHBOARDS, {}),
+ )
if hass.config.recovery_mode:
return True
- async def storage_dashboard_changed(change_type, item_id, item):
+ async def storage_dashboard_changed(
+ change_type: str, item_id: str, item: dict
+ ) -> None:
"""Handle a storage dashboard change."""
url_path = item[CONF_URL_PATH]
if change_type == collection.CHANGE_REMOVED:
frontend.async_remove_panel(hass, url_path)
- await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete()
+ await hass.data[LOVELACE_DATA].dashboards.pop(url_path).async_delete()
return
if change_type == collection.CHANGE_ADDED:
- existing = hass.data[DOMAIN]["dashboards"].get(url_path)
+ existing = hass.data[LOVELACE_DATA].dashboards.get(url_path)
if existing:
_LOGGER.warning(
@@ -183,13 +243,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return
- hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage(
+ hass.data[LOVELACE_DATA].dashboards[url_path] = dashboard.LovelaceStorage(
hass, item
)
update = False
else:
- hass.data[DOMAIN]["dashboards"][url_path].config = item
+ hass.data[LOVELACE_DATA].dashboards[url_path].config = item
update = True
try:
@@ -198,10 +258,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path)
# Process YAML dashboards
- for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items():
+ for url_path, dashboard_conf in hass.data[LOVELACE_DATA].yaml_dashboards.items():
# For now always mode=yaml
lovelace_config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf)
- hass.data[DOMAIN]["dashboards"][url_path] = lovelace_config
+ hass.data[LOVELACE_DATA].dashboards[url_path] = lovelace_config
try:
_register_panel(hass, url_path, MODE_YAML, dashboard_conf, False)
@@ -211,9 +271,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Process storage dashboards
dashboards_collection = dashboard.DashboardsCollection(hass)
- # This can be removed when the map integration is removed
- hass.data[DOMAIN]["dashboards_collection"] = dashboards_collection
-
dashboards_collection.async_add_listener(storage_dashboard_changed)
await dashboards_collection.async_load()
@@ -225,16 +282,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
STORAGE_DASHBOARD_UPDATE_FIELDS,
).async_setup(hass)
- def create_map_dashboard():
- hass.async_create_task(_create_map_dashboard(hass))
-
- if not onboarding.async_is_onboarded(hass):
- onboarding.async_add_listener(hass, create_map_dashboard)
-
return True
-async def create_yaml_resource_col(hass, yaml_resources):
+async def create_yaml_resource_col(
+ hass: HomeAssistant, yaml_resources: list[ConfigType] | None
+) -> resources.ResourceYAMLCollection:
"""Create yaml resources collection."""
if yaml_resources is None:
default_config = dashboard.LovelaceYAML(hass, None, None)
@@ -254,7 +307,9 @@ async def create_yaml_resource_col(hass, yaml_resources):
@callback
-def _register_panel(hass, url_path, mode, config, update):
+def _register_panel(
+ hass: HomeAssistant, url_path: str | None, mode: str, config: dict, update: bool
+) -> None:
"""Register a panel."""
kwargs = {
"frontend_url_path": url_path,
@@ -268,25 +323,3 @@ def _register_panel(hass, url_path, mode, config, update):
kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON)
frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)
-
-
-async def _create_map_dashboard(hass: HomeAssistant):
- translations = await async_get_translations(
- hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
- )
- title = translations["component.onboarding.dashboard.map.title"]
-
- dashboards_collection: dashboard.DashboardsCollection = hass.data[DOMAIN][
- "dashboards_collection"
- ]
- await dashboards_collection.async_create_item(
- {
- CONF_ALLOW_SINGLE_WORD: True,
- CONF_ICON: "mdi:map",
- CONF_TITLE: title,
- CONF_URL_PATH: "map",
- }
- )
-
- map_store: dashboard.LovelaceStorage = hass.data[DOMAIN]["dashboards"]["map"]
- await map_store.async_save({"strategy": {"type": "map"}})
diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py
index 82a92b94ae5..635425ba3dc 100644
--- a/homeassistant/components/lovelace/cast.py
+++ b/homeassistant/components/lovelace/cast.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import Any
+
from pychromecast import Chromecast
from pychromecast.const import CAST_TYPE_CHROMECAST
@@ -23,8 +25,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.network import NoURLAvailableError, get_url
-from .const import DOMAIN, ConfigNotFound
-from .dashboard import LovelaceConfig
+from .const import DOMAIN, LOVELACE_DATA, ConfigNotFound
DEFAULT_DASHBOARD = "_default_"
@@ -76,7 +77,7 @@ async def async_browse_media(
can_expand=False,
)
]
- for url_path in hass.data[DOMAIN]["dashboards"]:
+ for url_path in hass.data[LOVELACE_DATA].dashboards:
if url_path is None:
continue
@@ -101,7 +102,7 @@ async def async_browse_media(
BrowseMedia(
title=view["title"],
media_class=MediaClass.APP,
- media_content_id=f'{info["url_path"]}/{view["path"]}',
+ media_content_id=f"{info['url_path']}/{view['path']}",
media_content_type=DOMAIN,
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
can_play=True,
@@ -151,11 +152,13 @@ async def async_play_media(
return True
-async def _get_dashboard_info(hass, url_path):
+async def _get_dashboard_info(
+ hass: HomeAssistant, url_path: str | None
+) -> dict[str, Any]:
"""Load a dashboard and return info on views."""
if url_path == DEFAULT_DASHBOARD:
url_path = None
- dashboard: LovelaceConfig | None = hass.data[DOMAIN]["dashboards"].get(url_path)
+ dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path)
if dashboard is None:
raise ValueError("Invalid dashboard specified")
@@ -172,7 +175,7 @@ async def _get_dashboard_info(hass, url_path):
url_path = dashboard.url_path
title = config.get("title", url_path) if config else url_path
- views = []
+ views: list[dict[str, Any]] = []
data = {
"title": title,
"url_path": url_path,
diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py
index 86f47fe2b5c..0450c62338d 100644
--- a/homeassistant/components/lovelace/const.py
+++ b/homeassistant/components/lovelace/const.py
@@ -1,6 +1,8 @@
"""Constants for Lovelace."""
-from typing import Any
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
import voluptuous as vol
@@ -14,9 +16,13 @@ from homeassistant.const import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import VolDictType
-from homeassistant.util import slugify
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from . import LovelaceData
DOMAIN = "lovelace"
+LOVELACE_DATA: HassKey[LovelaceData] = HassKey(DOMAIN)
DEFAULT_ICON = "hass:view-dashboard"
@@ -84,18 +90,5 @@ STORAGE_DASHBOARD_CREATE_FIELDS: VolDictType = {
STORAGE_DASHBOARD_UPDATE_FIELDS = DASHBOARD_BASE_UPDATE_FIELDS
-def url_slug(value: Any) -> str:
- """Validate value is a valid url slug."""
- if value is None:
- raise vol.Invalid("Slug should not be None")
- if "-" not in value:
- raise vol.Invalid("Url path needs to contain a hyphen (-)")
- str_value = str(value)
- slg = slugify(str_value, separator="-")
- if str_value == slg:
- return str_value
- raise vol.Invalid(f"invalid slug {value} (try {slg})")
-
-
class ConfigNotFound(HomeAssistantError):
"""When no config available."""
diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py
index 411bbae9153..ddb54e7618f 100644
--- a/homeassistant/components/lovelace/dashboard.py
+++ b/homeassistant/components/lovelace/dashboard.py
@@ -7,7 +7,7 @@ import logging
import os
from pathlib import Path
import time
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -27,6 +27,7 @@ from .const import (
DOMAIN,
EVENT_LOVELACE_UPDATED,
LOVELACE_CONFIG_FILE,
+ LOVELACE_DATA,
MODE_STORAGE,
MODE_YAML,
STORAGE_DASHBOARD_CREATE_FIELDS,
@@ -66,21 +67,25 @@ class LovelaceConfig(ABC):
"""Return mode of the lovelace config."""
@abstractmethod
- async def async_get_info(self):
+ async def async_get_info(self) -> dict[str, Any]:
"""Return the config info."""
@abstractmethod
async def async_load(self, force: bool) -> dict[str, Any]:
"""Load config."""
- async def async_save(self, config):
+ async def async_save(self, config: dict[str, Any]) -> None:
"""Save config."""
raise HomeAssistantError("Not supported")
- async def async_delete(self):
+ async def async_delete(self) -> None:
"""Delete config."""
raise HomeAssistantError("Not supported")
+ @abstractmethod
+ async def async_json(self, force: bool) -> json_fragment:
+ """Return JSON representation of the config."""
+
@callback
def _config_updated(self) -> None:
"""Fire config updated event."""
@@ -112,7 +117,7 @@ class LovelaceStorage(LovelaceConfig):
"""Return mode of the lovelace config."""
return MODE_STORAGE
- async def async_get_info(self):
+ async def async_get_info(self) -> dict[str, Any]:
"""Return the Lovelace storage info."""
data = self._data or await self._load()
if data["config"] is None:
@@ -128,7 +133,7 @@ class LovelaceStorage(LovelaceConfig):
if (config := data["config"]) is None:
raise ConfigNotFound
- return config
+ return config # type: ignore[no-any-return]
async def async_json(self, force: bool) -> json_fragment:
"""Return JSON representation of the config."""
@@ -138,19 +143,21 @@ class LovelaceStorage(LovelaceConfig):
await self._load()
return self._json_config or self._async_build_json()
- async def async_save(self, config):
+ async def async_save(self, config: dict[str, Any]) -> None:
"""Save config."""
if self.hass.config.recovery_mode:
raise HomeAssistantError("Saving not supported in recovery mode")
if self._data is None:
await self._load()
+ if TYPE_CHECKING:
+ assert self._data is not None
self._data["config"] = config
self._json_config = None
self._config_updated()
await self._store.async_save(self._data)
- async def async_delete(self):
+ async def async_delete(self) -> None:
"""Delete config."""
if self.hass.config.recovery_mode:
raise HomeAssistantError("Deleting not supported in recovery mode")
@@ -194,7 +201,7 @@ class LovelaceYAML(LovelaceConfig):
"""Return mode of the lovelace config."""
return MODE_YAML
- async def async_get_info(self):
+ async def async_get_info(self) -> dict[str, Any]:
"""Return the YAML storage mode."""
try:
config = await self.async_load(False)
@@ -250,7 +257,7 @@ class LovelaceYAML(LovelaceConfig):
return is_updated, config, json
-def _config_info(mode, config):
+def _config_info(mode: str, config: dict[str, Any]) -> dict[str, Any]:
"""Generate info about the config."""
return {
"mode": mode,
@@ -264,7 +271,7 @@ class DashboardsCollection(collection.DictStorageCollection):
CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS)
UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS)
- def __init__(self, hass):
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the dashboards collection."""
super().__init__(
storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY),
@@ -282,12 +289,12 @@ class DashboardsCollection(collection.DictStorageCollection):
if url_path in self.hass.data[DATA_PANELS]:
raise vol.Invalid("Panel url path needs to be unique")
- return self.CREATE_SCHEMA(data)
+ return self.CREATE_SCHEMA(data) # type: ignore[no-any-return]
@callback
def _get_suggested_id(self, info: dict) -> str:
"""Suggest an ID based on the config."""
- return info[CONF_URL_PATH]
+ return info[CONF_URL_PATH] # type: ignore[no-any-return]
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
@@ -315,7 +322,7 @@ class DashboardsCollectionWebSocket(collection.DictStorageCollectionWebsocket):
msg["id"],
[
dashboard.config
- for dashboard in hass.data[DOMAIN]["dashboards"].values()
+ for dashboard in hass.data[LOVELACE_DATA].dashboards.values()
if dashboard.config
],
)
diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py
index 316a31e8e9d..96f84ccbc60 100644
--- a/homeassistant/components/lovelace/resources.py
+++ b/homeassistant/components/lovelace/resources.py
@@ -34,11 +34,11 @@ class ResourceYAMLCollection:
loaded = True
- def __init__(self, data):
+ def __init__(self, data: list[dict[str, Any]]) -> None:
"""Initialize a resource YAML collection."""
self.data = data
- async def async_get_info(self):
+ async def async_get_info(self) -> dict[str, int]:
"""Return the resources info for YAML mode."""
return {"resources": len(self.async_items() or [])}
@@ -62,7 +62,7 @@ class ResourceStorageCollection(collection.DictStorageCollection):
)
self.ll_config = ll_config
- async def async_get_info(self):
+ async def async_get_info(self) -> dict[str, int]:
"""Return the resources info for YAML mode."""
if not self.loaded:
await self.async_load()
diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py
index 1e703768ae6..b629614d10d 100644
--- a/homeassistant/components/lovelace/system_health.py
+++ b/homeassistant/components/lovelace/system_health.py
@@ -1,12 +1,13 @@
"""Provide info to system health."""
import asyncio
+from typing import Any
from homeassistant.components import system_health
from homeassistant.const import CONF_MODE
from homeassistant.core import HomeAssistant, callback
-from .const import DOMAIN, MODE_AUTO, MODE_STORAGE, MODE_YAML
+from .const import LOVELACE_DATA, MODE_AUTO, MODE_STORAGE, MODE_YAML
@callback
@@ -17,15 +18,17 @@ def async_register(
register.async_register_info(system_health_info, "/config/lovelace")
-async def system_health_info(hass):
+async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
- health_info = {"dashboards": len(hass.data[DOMAIN]["dashboards"])}
- health_info.update(await hass.data[DOMAIN]["resources"].async_get_info())
+ health_info: dict[str, Any] = {
+ "dashboards": len(hass.data[LOVELACE_DATA].dashboards)
+ }
+ health_info.update(await hass.data[LOVELACE_DATA].resources.async_get_info())
dashboards_info = await asyncio.gather(
*(
- hass.data[DOMAIN]["dashboards"][dashboard].async_get_info()
- for dashboard in hass.data[DOMAIN]["dashboards"]
+ hass.data[LOVELACE_DATA].dashboards[dashboard].async_get_info()
+ for dashboard in hass.data[LOVELACE_DATA].dashboards
)
)
@@ -39,7 +42,7 @@ async def system_health_info(hass):
else:
health_info[key] = dashboard[key]
- if hass.data[DOMAIN][CONF_MODE] == MODE_YAML:
+ if hass.data[LOVELACE_DATA].mode == MODE_YAML:
health_info[CONF_MODE] = MODE_YAML
elif MODE_STORAGE in modes:
health_info[CONF_MODE] = MODE_STORAGE
diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py
index e402ba92f16..5feb7deb449 100644
--- a/homeassistant/components/lovelace/websocket.py
+++ b/homeassistant/components/lovelace/websocket.py
@@ -2,8 +2,9 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable
from functools import wraps
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -13,11 +14,21 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_fragment
-from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound
-from .dashboard import LovelaceStorage
+from .const import CONF_URL_PATH, LOVELACE_DATA, ConfigNotFound
+from .dashboard import LovelaceConfig
+
+if TYPE_CHECKING:
+ from .resources import ResourceStorageCollection
+
+type AsyncLovelaceWebSocketCommandHandler[_R] = Callable[
+ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any], LovelaceConfig],
+ Awaitable[_R],
+]
-def _handle_errors(func):
+def _handle_errors[_R](
+ func: AsyncLovelaceWebSocketCommandHandler[_R],
+) -> websocket_api.AsyncWebSocketCommandHandler:
"""Handle error with WebSocket calls."""
@wraps(func)
@@ -27,7 +38,7 @@ def _handle_errors(func):
msg: dict[str, Any],
) -> None:
url_path = msg.get(CONF_URL_PATH)
- config: LovelaceStorage | None = hass.data[DOMAIN]["dashboards"].get(url_path)
+ config = hass.data[LOVELACE_DATA].dashboards.get(url_path)
if config is None:
connection.send_error(
@@ -74,7 +85,9 @@ async def websocket_lovelace_resources_impl(
This function is called by both Storage and YAML mode WS handlers.
"""
- resources = hass.data[DOMAIN]["resources"]
+ resources = hass.data[LOVELACE_DATA].resources
+ if TYPE_CHECKING:
+ assert isinstance(resources, ResourceStorageCollection)
if hass.config.safe_mode:
connection.send_result(msg["id"], [])
@@ -100,7 +113,7 @@ async def websocket_lovelace_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
- config: LovelaceStorage,
+ config: LovelaceConfig,
) -> json_fragment:
"""Send Lovelace UI config over WebSocket connection."""
return await config.async_json(msg["force"])
@@ -120,7 +133,7 @@ async def websocket_lovelace_save_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
- config: LovelaceStorage,
+ config: LovelaceConfig,
) -> None:
"""Save Lovelace UI configuration."""
await config.async_save(msg["config"])
@@ -139,7 +152,7 @@ async def websocket_lovelace_delete_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
- config: LovelaceStorage,
+ config: LovelaceConfig,
) -> None:
"""Delete Lovelace UI configuration."""
await config.async_delete()
diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py
index cf04cdb292a..0ce92538472 100644
--- a/homeassistant/components/luci/device_tracker.py
+++ b/homeassistant/components/luci/device_tracker.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py
index ba14afeb092..1ee444d5c84 100644
--- a/homeassistant/components/luftdaten/config_flow.py
+++ b/homeassistant/components/luftdaten/config_flow.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_SHOW_ON_MAP
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import CONF_SENSOR_ID, DOMAIN
diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py
index 26fc5ba153e..d697d6244b5 100644
--- a/homeassistant/components/lutron_caseta/__init__.py
+++ b/homeassistant/components/lutron_caseta/__init__.py
@@ -17,8 +17,11 @@ from homeassistant import config_entries
from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr, entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py
index a74de46346b..e56758b0af6 100644
--- a/homeassistant/components/lutron_caseta/button.py
+++ b/homeassistant/components/lutron_caseta/button.py
@@ -53,7 +53,7 @@ async def async_setup_entry(
# Append the child device name to the end of the parent keypad
# name to create the entity name
- full_name = f'{parent_device_info.get("name")} {device_name}'
+ full_name = f"{parent_device_info.get('name')} {device_name}"
# Set the device_info to the same as the Parent Keypad
# The entities will be nested inside the keypad device
entities.append(
diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py
index cd566b767fb..767c3d2f2b7 100644
--- a/homeassistant/components/lutron_caseta/config_flow.py
+++ b/homeassistant/components/lutron_caseta/config_flow.py
@@ -12,10 +12,10 @@ from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY, async_pair
from pylutron_caseta.smartbridge import Smartbridge
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import callback
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
ABORT_REASON_CANNOT_CONNECT,
@@ -69,7 +69,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA_USER)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
hostname = discovery_info.hostname
@@ -90,7 +90,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_link()
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by homekit discovery."""
return await self.async_step_zeroconf(discovery_info)
diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py
index 7493878bece..809b9e8d007 100644
--- a/homeassistant/components/lutron_caseta/const.py
+++ b/homeassistant/components/lutron_caseta/const.py
@@ -16,6 +16,7 @@ BRIDGE_DEVICE_ID = "1"
DEVICE_TYPE_WHITE_TUNE = "WhiteTune"
DEVICE_TYPE_SPECTRUM_TUNE = "SpectrumTune"
+DEVICE_TYPE_COLOR_TUNE = "ColorTune"
MANUFACTURER = "Lutron Electronics Co., Inc"
diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py
index 11da2220be9..d8fac38ce2b 100644
--- a/homeassistant/components/lutron_caseta/cover.py
+++ b/homeassistant/components/lutron_caseta/cover.py
@@ -99,6 +99,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaUpdatableEntity, CoverEntity):
PYLUTRON_TYPE_TO_CLASSES = {
"SerenaTiltOnlyWoodBlind": LutronCasetaTiltOnlyBlind,
+ "Tilt": LutronCasetaTiltOnlyBlind,
"SerenaHoneycombShade": LutronCasetaShade,
"SerenaRollerShade": LutronCasetaShade,
"TriathlonHoneycombShade": LutronCasetaShade,
diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py
index 0b432f88045..79b792935a8 100644
--- a/homeassistant/components/lutron_caseta/device_trigger.py
+++ b/homeassistant/components/lutron_caseta/device_trigger.py
@@ -277,6 +277,20 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
}
)
+PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = {
+ "button_0": 2,
+ "button_2": 4,
+}
+PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = {
+ "button_0": 0,
+ "button_2": 2,
+}
+PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
+ {
+ vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP),
+ }
+)
+
DEVICE_TYPE_SCHEMA_MAP = {
"Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA,
@@ -288,6 +302,7 @@ DEVICE_TYPE_SCHEMA_MAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
"FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
+ "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
@@ -300,6 +315,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP,
+ "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
@@ -312,6 +328,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP,
+ "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP,
}
LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = {
@@ -326,6 +343,7 @@ TRIGGER_SCHEMA = vol.Any(
PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
+ PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
)
diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py
index 146ed826c14..722c9a15d91 100644
--- a/homeassistant/components/lutron_caseta/light.py
+++ b/homeassistant/components/lutron_caseta/light.py
@@ -24,7 +24,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DEVICE_TYPE_SPECTRUM_TUNE, DEVICE_TYPE_WHITE_TUNE
+from .const import (
+ DEVICE_TYPE_COLOR_TUNE,
+ DEVICE_TYPE_SPECTRUM_TUNE,
+ DEVICE_TYPE_WHITE_TUNE,
+)
from .entity import LutronCasetaUpdatableEntity
from .models import LutronCasetaData
@@ -35,9 +39,18 @@ SUPPORTED_COLOR_MODE_DICT = {
ColorMode.WHITE,
},
DEVICE_TYPE_WHITE_TUNE: {ColorMode.COLOR_TEMP},
+ DEVICE_TYPE_COLOR_TUNE: {
+ ColorMode.HS,
+ ColorMode.COLOR_TEMP,
+ ColorMode.WHITE,
+ },
}
-WARM_DEVICE_TYPES = {DEVICE_TYPE_WHITE_TUNE, DEVICE_TYPE_SPECTRUM_TUNE}
+WARM_DEVICE_TYPES = {
+ DEVICE_TYPE_WHITE_TUNE,
+ DEVICE_TYPE_SPECTRUM_TUNE,
+ DEVICE_TYPE_COLOR_TUNE,
+}
def to_lutron_level(level):
@@ -90,8 +103,14 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
)
self.supports_warm_cool = light_type in WARM_DEVICE_TYPES
- self.supports_warm_dim = light_type == DEVICE_TYPE_SPECTRUM_TUNE
- self.supports_spectrum_tune = light_type == DEVICE_TYPE_SPECTRUM_TUNE
+ self.supports_warm_dim = light_type in (
+ DEVICE_TYPE_SPECTRUM_TUNE,
+ DEVICE_TYPE_COLOR_TUNE,
+ )
+ self.supports_spectrum_tune = light_type in (
+ DEVICE_TYPE_SPECTRUM_TUNE,
+ DEVICE_TYPE_COLOR_TUNE,
+ )
def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int:
"""Return minimum supported color temperature.
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index ec278615743..bbb6df41a89 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.22.0"],
+ "requirements": ["pylutron-caseta==0.23.0"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",
diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py
index 5037d077a02..66f23926fbf 100644
--- a/homeassistant/components/lutron_caseta/switch.py
+++ b/homeassistant/components/lutron_caseta/switch.py
@@ -44,7 +44,7 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
parent_keypad = keypads[device["parent_device"]]
parent_device_info = parent_keypad["device_info"]
# Append the child device name to the end of the parent keypad name to create the entity name
- self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}'
+ self._attr_name = f"{parent_device_info['name']} {device['device_name']}"
# Set the device_info to the same as the Parent Keypad
# The entities will be nested inside the keypad device
self._attr_device_info = parent_device_info
diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py
index 60741c861dd..7071cc9f416 100644
--- a/homeassistant/components/lw12wifi/light.py
+++ b/homeassistant/components/lw12wifi/light.py
@@ -20,10 +20,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py
index 87b5d566bb8..c5d17cfb176 100644
--- a/homeassistant/components/lyric/climate.py
+++ b/homeassistant/components/lyric/climate.py
@@ -34,8 +34,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json
index 1a4f0f79aae..19f23afddaf 100644
--- a/homeassistant/components/madvr/strings.json
+++ b/homeassistant/components/madvr/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "title": "Setup madVR Envy",
+ "title": "Set up madVR Envy",
"description": "Your device needs to be on in order to add the integation.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -21,8 +21,8 @@
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
- "host": "The hostname or IP address of your madVR Envy device.",
- "port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default."
+ "host": "[%key:component::madvr::config::step::user::data_description::host%]",
+ "port": "[%key:component::madvr::config::step::user::data_description::port%]"
}
}
},
@@ -33,7 +33,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
+ "no_mac": "A MAC address was not found. It is required to identify the device. Please ensure your device is connectable."
}
},
"entity": {
diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py
index 72617b2f42d..eb704a2d797 100644
--- a/homeassistant/components/mailgun/__init__.py
+++ b/homeassistant/components/mailgun/__init__.py
@@ -12,8 +12,7 @@ from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py
index 244f38e0902..2b4d680208e 100644
--- a/homeassistant/components/manual/alarm_control_panel.py
+++ b/homeassistant/components/manual/alarm_control_panel.py
@@ -25,13 +25,13 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
DOMAIN = "manual"
diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py
index 768690e8ec5..cb03b71ce22 100644
--- a/homeassistant/components/manual_mqtt/alarm_control_panel.py
+++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py
@@ -26,14 +26,14 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py
index 89832c01937..08d78ecf5c3 100644
--- a/homeassistant/components/marytts/tts.py
+++ b/homeassistant/components/marytts/tts.py
@@ -11,7 +11,7 @@ from homeassistant.components.tts import (
Provider,
)
from homeassistant.const import CONF_EFFECT, CONF_HOST, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
CONF_VOICE = "voice"
CONF_CODEC = "codec"
diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py
index a36ba2e917f..1b93cbecd98 100644
--- a/homeassistant/components/mastodon/config_flow.py
+++ b/homeassistant/components/mastodon/config_flow.py
@@ -9,12 +9,7 @@ import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import (
- CONF_ACCESS_TOKEN,
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- CONF_NAME,
-)
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
@@ -22,7 +17,7 @@ from homeassistant.helpers.selector import (
)
from homeassistant.util import slugify
-from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER
+from .const import CONF_BASE_URL, DOMAIN, LOGGER
from .utils import construct_mastodon_username, create_mastodon_client
STEP_USER_DATA_SCHEMA = vol.Schema(
@@ -130,44 +125,3 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.show_user_form(user_input, errors)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import a config entry from configuration.yaml."""
- errors: dict[str, str] | None = None
-
- LOGGER.debug("Importing Mastodon from configuration.yaml")
-
- base_url = base_url_from_url(str(import_data.get(CONF_BASE_URL, DEFAULT_URL)))
- client_id = str(import_data.get(CONF_CLIENT_ID))
- client_secret = str(import_data.get(CONF_CLIENT_SECRET))
- access_token = str(import_data.get(CONF_ACCESS_TOKEN))
- name = import_data.get(CONF_NAME)
-
- instance, account, errors = await self.hass.async_add_executor_job(
- self.check_connection,
- base_url,
- client_id,
- client_secret,
- access_token,
- )
-
- if not errors:
- name = construct_mastodon_username(instance, account)
- await self.async_set_unique_id(slugify(name))
- self._abort_if_unique_id_configured()
-
- if not name:
- name = construct_mastodon_username(instance, account)
-
- return self.async_create_entry(
- title=name,
- data={
- CONF_BASE_URL: base_url,
- CONF_CLIENT_ID: client_id,
- CONF_CLIENT_SECRET: client_secret,
- CONF_ACCESS_TOKEN: access_token,
- },
- )
-
- reason = next(iter(errors.items()))[1]
- return self.async_abort(reason=reason)
diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py
index 7878fc665a1..dd76d44a02c 100644
--- a/homeassistant/components/mastodon/notify.py
+++ b/homeassistant/components/mastodon/notify.py
@@ -14,14 +14,12 @@ from homeassistant.components.notify import (
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService,
)
-from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER
+from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER
ATTR_MEDIA = "media"
ATTR_TARGET = "target"
@@ -46,51 +44,7 @@ async def async_get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MastodonNotificationService | None:
"""Get the Mastodon notification service."""
-
- if not discovery_info:
- # Import config entry
-
- import_result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
-
- if (
- import_result["type"] == FlowResultType.ABORT
- and import_result["reason"] != "already_configured"
- ):
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{import_result["reason"]}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=ir.IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{import_result["reason"]}",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": INTEGRATION_TITLE,
- },
- )
- return None
-
- ir.async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=ir.IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": INTEGRATION_TITLE,
- },
- )
-
+ if discovery_info is None:
return None
client: Mastodon = discovery_info.get("client")
@@ -161,7 +115,7 @@ class MastodonNotificationService(BaseNotificationService):
try:
mediadata = self.client.media_post(media_path, mime_type=media_type)
except MastodonAPIError:
- LOGGER.error(f"Unable to upload image {media_path}")
+ LOGGER.error("Unable to upload image %s", media_path)
return mediadata
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index c6aefefca06..9df94ecf204 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -25,20 +25,6 @@
"unknown": "Unknown error occured when connecting to the Mastodon instance."
}
},
- "issues": {
- "deprecated_yaml_import_issue_unauthorized_error": {
- "title": "YAML import failed due to an authentication error",
- "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
- },
- "deprecated_yaml_import_issue_network_error": {
- "title": "YAML import failed because the instance was not found",
- "description": "Configuring {integration_title} using YAML is being removed but no instance was found while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
- },
- "deprecated_yaml_import_issue_unknown": {
- "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": {
diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py
index e1b488c0fce..8640aa4d074 100644
--- a/homeassistant/components/matrix/__init__.py
+++ b/homeassistant/components/matrix/__init__.py
@@ -39,7 +39,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonObjectType, load_json_object
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
index e06eed1176f..b173a2c850b 100644
--- a/homeassistant/components/matrix/manifest.json
+++ b/homeassistant/components/matrix/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
- "requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"]
+ "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"]
}
diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py
index b05c7952d1f..0fc08e6c5aa 100644
--- a/homeassistant/components/matrix/notify.py
+++ b/homeassistant/components/matrix/notify.py
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import RoomID
diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py
index e751387d7e8..e3e30fb704b 100644
--- a/homeassistant/components/matter/__init__.py
+++ b/homeassistant/components/matter/__init__.py
@@ -178,7 +178,7 @@ async def _client_listen(
if entry.state != ConfigEntryState.LOADED:
raise
LOGGER.error("Failed to listen: %s", err)
- except Exception as err: # noqa: BLE001
+ except Exception as err:
# We need to guard against unknown exceptions to not crash this task.
LOGGER.exception("Unexpected exception: %s", err)
if entry.state != ConfigEntryState.LOADED:
diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py
index 0ccd3e065ff..dad780d9a87 100644
--- a/homeassistant/components/matter/adapter.py
+++ b/homeassistant/components/matter/adapter.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
from chip.clusters import Objects as clusters
-from matter_server.client.models.device_types import BridgedDevice
+from matter_server.client.models.device_types import BridgedNode
from matter_server.common.models import EventType, ServerInfoMessage
from homeassistant.config_entries import ConfigEntry
@@ -162,7 +162,7 @@ class MatterAdapter:
(
x
for x in endpoint.device_types
- if x.device_type != BridgedDevice.device_type
+ if x.device_type != BridgedNode.device_type
),
None,
)
diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py
index 153124a4f7e..634406d18eb 100644
--- a/homeassistant/components/matter/button.py
+++ b/homeassistant/components/matter/button.py
@@ -49,11 +49,7 @@ class MatterCommandButton(MatterEntity, ButtonEntity):
"""Handle the button press leveraging a Matter command."""
if TYPE_CHECKING:
assert self.entity_description.command is not None
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=self.entity_description.command(),
- )
+ await self.send_device_command(self.entity_description.command())
# Discovery schema(s) to map Matter Attributes to HA entities
@@ -67,8 +63,8 @@ DISCOVERY_SCHEMAS = [
command=lambda: clusters.Identify.Commands.Identify(identifyTime=15),
),
entity_class=MatterCommandButton,
- required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,),
- value_contains=clusters.Identify.Commands.Identify.command_id,
+ required_attributes=(clusters.Identify.Attributes.IdentifyType,),
+ value_is_not=clusters.Identify.Enums.IdentifyTypeEnum.kNone,
allow_multi=True,
),
MatterDiscoverySchema(
diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py
index 0378d0ea226..25419c34e42 100644
--- a/homeassistant/components/matter/climate.py
+++ b/homeassistant/components/matter/climate.py
@@ -212,57 +212,45 @@ class MatterClimate(MatterEntity, ClimateEntity):
matter_attribute = (
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- matter_attribute,
- ),
+ await self.write_attribute(
value=int(target_temperature * TEMPERATURE_SCALING_FACTOR),
+ matter_attribute=matter_attribute,
)
return
if target_temperature_low is not None:
# multi setpoint control - low setpoint (heat)
if self.target_temperature_low != target_temperature_low:
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
- ),
+ await self.write_attribute(
value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
+ matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
)
if target_temperature_high is not None:
# multi setpoint control - high setpoint (cool)
if self.target_temperature_high != target_temperature_high:
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
- ),
+ await self.write_attribute(
value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
+ matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
- system_mode_path = create_attribute_path_from_attribute(
- endpoint_id=self._endpoint.endpoint_id,
- attribute=clusters.Thermostat.Attributes.SystemMode,
- )
+
system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode)
if system_mode_value is None:
raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter")
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=system_mode_path,
+ await self.write_attribute(
value=system_mode_value,
+ matter_attribute=clusters.Thermostat.Attributes.SystemMode,
)
# we need to optimistically update the attribute's value here
# to prevent a race condition when adjusting the mode and temperature
# in the same call
+ system_mode_path = create_attribute_path_from_attribute(
+ endpoint_id=self._endpoint.endpoint_id,
+ attribute=clusters.Thermostat.Attributes.SystemMode,
+ )
self._endpoint.set_attribute_value(system_mode_path, system_mode_value)
self._update_from_device()
@@ -310,13 +298,11 @@ class MatterClimate(MatterEntity, ClimateEntity):
):
match running_state_value:
case (
- ThermostatRunningState.Heat
- | ThermostatRunningState.HeatStage2
+ ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
):
self._attr_hvac_action = HVACAction.HEATING
case (
- ThermostatRunningState.Cool
- | ThermostatRunningState.CoolStage2
+ ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
):
self._attr_hvac_action = HVACAction.COOLING
case (
@@ -447,5 +433,6 @@ DISCOVERY_SCHEMAS = [
clusters.OnOff.Attributes.OnOff,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
+ allow_multi=True, # also used for sensor entity
),
]
diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py
index 6f7505eb61f..0c73ccd4089 100644
--- a/homeassistant/components/matter/config_flow.py
+++ b/homeassistant/components/matter/config_flow.py
@@ -16,7 +16,6 @@ from homeassistant.components.hassio import (
AddonState,
)
from homeassistant.components.onboarding import async_is_onboarded
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
@@ -25,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .addon import get_addon_manager
from .const import (
diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py
index ba9c3afbdee..5b109d52189 100644
--- a/homeassistant/components/matter/cover.py
+++ b/homeassistant/components/matter/cover.py
@@ -102,14 +102,6 @@ class MatterCover(MatterEntity, CoverEntity):
clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100)
)
- async def send_device_command(self, command: Any) -> None:
- """Send device command."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=command,
- )
-
@callback
def _update_from_device(self) -> None:
"""Update from device."""
diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py
index 3b9fb0b8a94..7102b693e45 100644
--- a/homeassistant/components/matter/discovery.py
+++ b/homeassistant/components/matter/discovery.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Generator
-from chip.clusters.Objects import ClusterAttributeDescriptor
+from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, NullValue
from matter_server.client.models.node import MatterEndpoint
from homeassistant.const import Platform
@@ -19,7 +19,7 @@ from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS
from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS
from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS
-from .models import MatterDiscoverySchema, MatterEntityInfo
+from .models import UNSET, MatterDiscoverySchema, MatterEntityInfo
from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS
from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS
from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS
@@ -67,6 +67,8 @@ def async_discover_entities(
if any(x in schema.required_attributes for x in discovered_attributes):
continue
+ primary_attribute = schema.required_attributes[0]
+
# check vendor_id
if (
schema.vendor_id is not None
@@ -121,15 +123,6 @@ def async_discover_entities(
):
continue
- # check for required value in (primary) attribute
- primary_attribute = schema.required_attributes[0]
- primary_value = endpoint.get_attribute_value(None, primary_attribute)
- if schema.value_contains is not None and (
- isinstance(primary_value, list)
- and schema.value_contains not in primary_value
- ):
- continue
-
# check for required value in cluster featuremap
if schema.featuremap_contains is not None and (
not bool(
@@ -143,6 +136,61 @@ def async_discover_entities(
):
continue
+ # BEGIN checks on actual attribute values
+ # these are the least likely to be used and least efficient, so they are checked last
+
+ # check if PRIMARY value exists but is none/null
+ if not schema.allow_none_value and any(
+ endpoint.get_attribute_value(None, val_schema) in (None, NullValue)
+ for val_schema in schema.required_attributes
+ ):
+ continue
+
+ # check for required value in PRIMARY attribute
+ primary_value = endpoint.get_attribute_value(None, primary_attribute)
+ if schema.value_contains is not UNSET and (
+ isinstance(primary_value, list)
+ and schema.value_contains not in primary_value
+ ):
+ continue
+
+ # check for value that may not be present in PRIMARY attribute
+ if schema.value_is_not is not UNSET and (
+ schema.value_is_not == primary_value
+ or (
+ isinstance(primary_value, list) and schema.value_is_not in primary_value
+ )
+ ):
+ continue
+
+ # check for value that may not be present in SECONDARY attribute
+ secondary_attribute = (
+ schema.required_attributes[1]
+ if len(schema.required_attributes) > 1
+ else None
+ )
+ secondary_value = (
+ endpoint.get_attribute_value(None, secondary_attribute)
+ if secondary_attribute
+ else None
+ )
+ if schema.secondary_value_is_not is not UNSET and (
+ (schema.secondary_value_is_not == secondary_value)
+ or (
+ isinstance(secondary_value, list)
+ and schema.secondary_value_is_not in secondary_value
+ )
+ ):
+ continue
+
+ # check for required value in SECONDARY attribute
+ if schema.secondary_value_contains is not UNSET and (
+ isinstance(secondary_value, list)
+ and schema.secondary_value_contains not in secondary_value
+ ):
+ continue
+
+ # FINISH all validation checks
# all checks passed, this value belongs to an entity
attributes_to_watch = list(schema.required_attributes)
diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py
index 50a0f2b1fee..96696193466 100644
--- a/homeassistant/components/matter/entity.py
+++ b/homeassistant/components/matter/entity.py
@@ -2,21 +2,27 @@
from __future__ import annotations
-from collections.abc import Callable
+from collections.abc import Callable, Coroutine
from dataclasses import dataclass
+import functools
import logging
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, Concatenate, cast
from chip.clusters import Objects as clusters
-from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue
-from matter_server.common.helpers.util import create_attribute_path
+from chip.clusters.Objects import ClusterAttributeDescriptor, ClusterCommand, NullValue
+from matter_server.common.errors import MatterError
+from matter_server.common.helpers.util import (
+ create_attribute_path,
+ create_attribute_path_from_attribute,
+)
from matter_server.common.models import EventType, ServerInfoMessage
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
-import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import UndefinedType
from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID
@@ -31,6 +37,23 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
+def catch_matter_error[_R, **P](
+ func: Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]]:
+ """Catch Matter errors and convert to Home Assistant error."""
+
+ @functools.wraps(func)
+ async def wrapper(self: MatterEntity, *args: P.args, **kwargs: P.kwargs) -> _R:
+ """Catch Matter errors and convert to Home Assistant error."""
+ try:
+ return await func(self, *args, **kwargs)
+ except MatterError as err:
+ error_msg = str(err) or err.__class__.__name__
+ raise HomeAssistantError(error_msg) from err
+
+ return wrapper
+
+
@dataclass(frozen=True)
class MatterEntityDescription(EntityDescription):
"""Describe the Matter entity."""
@@ -218,3 +241,38 @@ class MatterEntity(Entity):
return create_attribute_path(
self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id
)
+
+ @catch_matter_error
+ async def send_device_command(
+ self,
+ command: ClusterCommand,
+ **kwargs: Any,
+ ) -> None:
+ """Send device command on the primary attribute's endpoint."""
+ await self.matter_client.send_device_command(
+ node_id=self._endpoint.node.node_id,
+ endpoint_id=self._endpoint.endpoint_id,
+ command=command,
+ **kwargs,
+ )
+
+ @catch_matter_error
+ async def write_attribute(
+ self,
+ value: Any,
+ matter_attribute: type[ClusterAttributeDescriptor] | None = None,
+ ) -> Any:
+ """Write an attribute(value) on the primary endpoint.
+
+ If matter_attribute is not provided, the primary attribute of the entity is used.
+ """
+ if matter_attribute is None:
+ matter_attribute = self._entity_info.primary_attribute
+ return await self.matter_client.write_attribute(
+ node_id=self._endpoint.node.node_id,
+ attribute_path=create_attribute_path_from_attribute(
+ self._endpoint.endpoint_id,
+ matter_attribute,
+ ),
+ value=value,
+ )
diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py
index 593693dbbf9..8b8ebee619d 100644
--- a/homeassistant/components/matter/fan.py
+++ b/homeassistant/components/matter/fan.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from chip.clusters import Objects as clusters
-from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.fan import (
DIRECTION_FORWARD,
@@ -97,24 +96,16 @@ class MatterFan(MatterEntity, FanEntity):
# clear the wind setting if its currently set
if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
await self._set_wind_mode(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,
- clusters.FanControl.Attributes.FanMode,
- ),
+ await self.write_attribute(
value=clusters.FanControl.Enums.FanModeEnum.kOff,
+ matter_attribute=clusters.FanControl.Attributes.FanMode,
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- clusters.FanControl.Attributes.PercentSetting,
- ),
+ await self.write_attribute(
value=percentage,
+ matter_attribute=clusters.FanControl.Attributes.PercentSetting,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
@@ -128,41 +119,33 @@ class MatterFan(MatterEntity, FanEntity):
if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
await self._set_wind_mode(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,
- clusters.FanControl.Attributes.FanMode,
- ),
+ await self.write_attribute(
value=FAN_MODE_MAP[preset_mode],
+ matter_attribute=clusters.FanControl.Attributes.FanMode,
)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- clusters.FanControl.Attributes.RockSetting,
+ await self.write_attribute(
+ value=(
+ self.get_matter_attribute_value(
+ clusters.FanControl.Attributes.RockSupport
+ )
+ if oscillating
+ else 0
),
- value=self.get_matter_attribute_value(
- clusters.FanControl.Attributes.RockSupport
- )
- if oscillating
- else 0,
+ matter_attribute=clusters.FanControl.Attributes.RockSetting,
)
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- clusters.FanControl.Attributes.AirflowDirection,
+ await self.write_attribute(
+ value=(
+ clusters.FanControl.Enums.AirflowDirectionEnum.kReverse
+ if direction == DIRECTION_REVERSE
+ else clusters.FanControl.Enums.AirflowDirectionEnum.kForward
),
- value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse
- if direction == DIRECTION_REVERSE
- else clusters.FanControl.Enums.AirflowDirectionEnum.kForward,
+ matter_attribute=clusters.FanControl.Attributes.AirflowDirection,
)
async def _set_wind_mode(self, wind_mode: str | None) -> None:
@@ -173,13 +156,9 @@ class MatterFan(MatterEntity, FanEntity):
wind_setting = WindBitmap.kSleepWind
else:
wind_setting = 0
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- clusters.FanControl.Attributes.WindSetting,
- ),
+ await self.write_attribute(
value=wind_setting,
+ matter_attribute=clusters.FanControl.Attributes.WindSetting,
)
@callback
diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json
index ef29601b831..f9217cabcc4 100644
--- a/homeassistant/components/matter/icons.json
+++ b/homeassistant/components/matter/icons.json
@@ -36,10 +36,21 @@
}
}
},
+ "select": {
+ "laundry_washer_spin_speed": {
+ "default": "mdi:reload"
+ },
+ "temperature_level": {
+ "default": "mdi:thermometer"
+ }
+ },
"sensor": {
"contamination_state": {
"default": "mdi:air-filter"
},
+ "current_phase": {
+ "default": "mdi:state-machine"
+ },
"air_quality": {
"default": "mdi:air-filter"
},
@@ -61,6 +72,15 @@
"battery_replacement_description": {
"default": "mdi:battery-sync-outline"
}
+ },
+ "switch": {
+ "child_lock": {
+ "default": "mdi:lock",
+ "state": {
+ "on": "mdi:lock",
+ "off": "mdi:lock-off"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py
index c9d5c688f69..5c20554f065 100644
--- a/homeassistant/components/matter/light.py
+++ b/homeassistant/components/matter/light.py
@@ -40,9 +40,9 @@ from .util import (
)
COLOR_MODE_MAP = {
- clusters.ColorControl.Enums.ColorMode.kCurrentHueAndCurrentSaturation: ColorMode.HS,
- clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY,
- clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP,
+ clusters.ColorControl.Enums.ColorModeEnum.kCurrentHueAndCurrentSaturation: ColorMode.HS,
+ clusters.ColorControl.Enums.ColorModeEnum.kCurrentXAndCurrentY: ColorMode.XY,
+ clusters.ColorControl.Enums.ColorModeEnum.kColorTemperatureMireds: ColorMode.COLOR_TEMP,
}
# there's a bug in (at least) Espressif's implementation of light transitions
@@ -282,14 +282,6 @@ class MatterLight(MatterEntity, LightEntity):
return ha_color_mode
- async def send_device_command(self, command: Any) -> None:
- """Send device command."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=command,
- )
-
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""
@@ -355,21 +347,21 @@ class MatterLight(MatterEntity, LightEntity):
if (
capabilities
- & clusters.ColorControl.Bitmaps.ColorCapabilities.kHueSaturationSupported
+ & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kHueSaturation
):
supported_color_modes.add(ColorMode.HS)
self._supports_color = True
if (
capabilities
- & clusters.ColorControl.Bitmaps.ColorCapabilities.kXYAttributesSupported
+ & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kXy
):
supported_color_modes.add(ColorMode.XY)
self._supports_color = True
if (
capabilities
- & clusters.ColorControl.Bitmaps.ColorCapabilities.kColorTemperatureSupported
+ & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kColorTemperature
):
supported_color_modes.add(ColorMode.COLOR_TEMP)
self._supports_color_temperature = True
diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py
index d69d0fd3dab..8524b39d584 100644
--- a/homeassistant/components/matter/lock.py
+++ b/homeassistant/components/matter/lock.py
@@ -62,19 +62,6 @@ class MatterLock(MatterEntity, LockEntity):
return None
- async def send_device_command(
- self,
- command: clusters.ClusterCommand,
- timed_request_timeout_ms: int = 1000,
- ) -> None:
- """Send a command to the device."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=command,
- timed_request_timeout_ms=timed_request_timeout_ms,
- )
-
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock with pin if needed."""
if not self._attr_is_locked:
@@ -89,7 +76,8 @@ class MatterLock(MatterEntity, LockEntity):
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
await self.send_device_command(
- command=clusters.DoorLock.Commands.LockDoor(code_bytes)
+ command=clusters.DoorLock.Commands.LockDoor(code_bytes),
+ timed_request_timeout_ms=1000,
)
async def async_unlock(self, **kwargs: Any) -> None:
@@ -110,11 +98,13 @@ class MatterLock(MatterEntity, LockEntity):
# the unlock command should unbolt only on the unlock command
# and unlatch on the HA 'open' command.
await self.send_device_command(
- command=clusters.DoorLock.Commands.UnboltDoor(code_bytes)
+ command=clusters.DoorLock.Commands.UnboltDoor(code_bytes),
+ timed_request_timeout_ms=1000,
)
else:
await self.send_device_command(
- command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)
+ command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
+ timed_request_timeout_ms=1000,
)
async def async_open(self, **kwargs: Any) -> None:
@@ -130,7 +120,8 @@ class MatterLock(MatterEntity, LockEntity):
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
await self.send_device_command(
- command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)
+ command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
+ timed_request_timeout_ms=1000,
)
@callback
diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json
index 4573fe17401..669fa1af8c4 100644
--- a/homeassistant/components/matter/manifest.json
+++ b/homeassistant/components/matter/manifest.json
@@ -7,6 +7,6 @@
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/matter",
"iot_class": "local_push",
- "requirements": ["python-matter-server==6.6.0"],
+ "requirements": ["python-matter-server==7.0.0"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
}
diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py
index a00963c825a..4af7cc3c026 100644
--- a/homeassistant/components/matter/models.py
+++ b/homeassistant/components/matter/models.py
@@ -18,6 +18,14 @@ type SensorValueTypes = type[
]
+# A sentinel object to detect if a parameter is supplied or not.
+class _UNSET_TYPE:
+ pass
+
+
+UNSET = _UNSET_TYPE()
+
+
class MatterDeviceInfo(TypedDict):
"""Dictionary with Matter Device info.
@@ -111,11 +119,6 @@ class MatterDiscoverySchema:
# are not discovered by other entities
optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None
- # [optional] the primary attribute value must contain this value
- # for example for the AcceptedCommandList
- # NOTE: only works for list values
- value_contains: Any | None = None
-
# [optional] the primary attribute's cluster featuremap must contain this value
# for example for the DoorSensor on a DoorLock Cluster
featuremap_contains: int | None = None
@@ -123,3 +126,25 @@ class MatterDiscoverySchema:
# [optional] bool to specify if this primary value may be discovered
# by multiple platforms
allow_multi: bool = False
+
+ # [optional] the primary attribute value may not be null/None
+ allow_none_value: bool = False
+
+ # [optional] the primary attribute value must contain this value
+ # for example for the AcceptedCommandList
+ # NOTE: only works for list values
+ value_contains: Any = UNSET
+
+ # [optional] the secondary (required) attribute value must contain this value
+ # for example for the AcceptedCommandList
+ # NOTE: only works for list values
+ secondary_value_contains: Any = UNSET
+
+ # [optional] the primary attribute value must NOT have this value
+ # for example to filter out invalid values (such as empty string instead of null)
+ # in case of a list value, the list may not contain this value
+ value_is_not: Any = UNSET
+
+ # [optional] the secondary (required) attribute value must NOT have this value
+ # for example to filter out empty lists in list sensor values
+ secondary_value_is_not: Any = UNSET
diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py
index cc312cdc66a..93b6b8f75c9 100644
--- a/homeassistant/components/matter/number.py
+++ b/homeassistant/components/matter/number.py
@@ -6,7 +6,6 @@ from dataclasses import dataclass
from chip.clusters import Objects as clusters
from matter_server.common import custom_clusters
-from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.number import (
NumberDeviceClass,
@@ -15,7 +14,13 @@ from homeassistant.components.number import (
NumberMode,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import EntityCategory, Platform, UnitOfLength, UnitOfTime
+from homeassistant.const import (
+ EntityCategory,
+ Platform,
+ UnitOfLength,
+ UnitOfTemperature,
+ UnitOfTime,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -46,16 +51,10 @@ class MatterNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
- matter_attribute = self._entity_info.primary_attribute
sendvalue = int(value)
if value_convert := self.entity_description.ha_to_native_value:
sendvalue = value_convert(value)
- await self.matter_client.write_attribute(
- node_id=self._endpoint.node.node_id,
- attribute_path=create_attribute_path_from_attribute(
- self._endpoint.endpoint_id,
- matter_attribute,
- ),
+ await self.write_attribute(
value=sendvalue,
)
@@ -87,6 +86,8 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.OnLevel,),
+ # allow None value to account for 'default' value
+ allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
@@ -104,6 +105,8 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.OnTransitionTime,),
+ # allow None value to account for 'default' value
+ allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
@@ -121,6 +124,8 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.OffTransitionTime,),
+ # allow None value to account for 'default' value
+ allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
@@ -138,6 +143,8 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,),
+ # allow None value to account for 'default' value
+ allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
@@ -155,4 +162,25 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,),
),
+ MatterDiscoverySchema(
+ platform=Platform.NUMBER,
+ entity_description=MatterNumberEntityDescription(
+ key="EveTemperatureOffset",
+ device_class=NumberDeviceClass.TEMPERATURE,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="temperature_offset",
+ native_max_value=25,
+ native_min_value=-25,
+ native_step=0.5,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ measurement_to_ha=lambda x: None if x is None else x / 10,
+ ha_to_native_value=lambda x: round(x * 10),
+ mode=NumberMode.BOX,
+ ),
+ entity_class=MatterNumber,
+ required_attributes=(
+ clusters.Thermostat.Attributes.LocalTemperatureCalibration,
+ ),
+ vendor_id=(4874,),
+ ),
]
diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py
index 1a2fc36c014..dd4f8314bef 100644
--- a/homeassistant/components/matter/select.py
+++ b/homeassistant/components/matter/select.py
@@ -2,12 +2,13 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
from chip.clusters import Objects as clusters
+from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
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
@@ -19,6 +20,17 @@ from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .models import MatterDiscoverySchema
+NUMBER_OF_RINSES_STATE_MAP = {
+ clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNone: "off",
+ clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNormal: "normal",
+ clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kExtra: "extra",
+ clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kMax: "max",
+ clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kUnknownEnumValue: None,
+}
+NUMBER_OF_RINSES_STATE_MAP_REVERSE = {
+ v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items()
+}
+
type SelectCluster = (
clusters.ModeSelect
| clusters.OvenMode
@@ -47,7 +59,30 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip
"""Describe Matter select entities."""
-class MatterSelectEntity(MatterEntity, SelectEntity):
+@dataclass(frozen=True, kw_only=True)
+class MatterMapSelectEntityDescription(MatterSelectEntityDescription):
+ """Describe Matter select entities for MatterMapSelectEntityDescription."""
+
+ measurement_to_ha: Callable[[int], str | None]
+ ha_to_native_value: Callable[[str], int | None]
+
+ # list attribute: the attribute descriptor to get the list of values (= list of integers)
+ list_attribute: type[ClusterAttributeDescriptor]
+
+
+@dataclass(frozen=True, kw_only=True)
+class MatterListSelectEntityDescription(MatterSelectEntityDescription):
+ """Describe Matter select entities for MatterListSelectEntity."""
+
+ # list attribute: the attribute descriptor to get the list of values (= list of strings)
+ list_attribute: type[ClusterAttributeDescriptor]
+ # command: a custom callback to create the command to send to the device
+ # the callback's argument will be the index of the selected list value
+ # if omitted the command will just be a write_attribute command to the primary attribute
+ command: Callable[[int], ClusterCommand] | None = None
+
+
+class MatterAttributeSelectEntity(MatterEntity, SelectEntity):
"""Representation of a select entity from Matter Attribute read/write."""
entity_description: MatterSelectEntityDescription
@@ -57,11 +92,7 @@ class MatterSelectEntity(MatterEntity, SelectEntity):
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
- ),
+ await self.write_attribute(
value=value_convert(option),
)
@@ -76,7 +107,30 @@ class MatterSelectEntity(MatterEntity, SelectEntity):
self._attr_current_option = value_convert(value)
-class MatterModeSelectEntity(MatterSelectEntity):
+class MatterMapSelectEntity(MatterAttributeSelectEntity):
+ """Representation of a Matter select entity where the options are defined in a State map."""
+
+ entity_description: MatterMapSelectEntityDescription
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ # the options can dynamically change based on the state of the device
+ available_values = cast(
+ list[int],
+ self.get_matter_attribute_value(self.entity_description.list_attribute),
+ )
+ # map available (int) values to string representation
+ self._attr_options = [
+ mapped_value
+ for value in available_values
+ if (mapped_value := self.entity_description.measurement_to_ha(value))
+ ]
+ # use base implementation from MatterAttributeSelectEntity to set the current option
+ super()._update_from_device()
+
+
+class MatterModeSelectEntity(MatterAttributeSelectEntity):
"""Representation of a select entity from Matter (Mode) Cluster attribute(s)."""
async def async_select_option(self, option: str) -> None:
@@ -88,10 +142,8 @@ class MatterModeSelectEntity(MatterSelectEntity):
for mode in cluster.supportedModes:
if mode.label != option:
continue
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=cluster.Commands.ChangeToMode(newMode=mode.mode),
+ await self.send_device_command(
+ cluster.Commands.ChangeToMode(newMode=mode.mode),
)
break
@@ -111,6 +163,46 @@ class MatterModeSelectEntity(MatterSelectEntity):
self._attr_name = desc
+class MatterListSelectEntity(MatterEntity, SelectEntity):
+ """Representation of a select entity from Matter list and selected item Cluster attribute(s)."""
+
+ entity_description: MatterListSelectEntityDescription
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ option_id = self._attr_options.index(option)
+
+ if TYPE_CHECKING:
+ assert option_id is not None
+
+ if self.entity_description.command:
+ # custom command defined to set the new value
+ await self.send_device_command(
+ self.entity_description.command(option_id),
+ )
+ return
+ # regular write attribute to set the new value
+ await self.write_attribute(
+ value=option_id,
+ )
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ list_values = cast(
+ list[str],
+ self.get_matter_attribute_value(self.entity_description.list_attribute),
+ )
+ self._attr_options = list_values
+ current_option_idx: int = self.get_matter_attribute_value(
+ self._entity_info.primary_attribute
+ )
+ try:
+ self._attr_current_option = list_values[current_option_idx]
+ except IndexError:
+ self._attr_current_option = None
+
+
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -125,6 +217,8 @@ DISCOVERY_SCHEMAS = [
clusters.ModeSelect.Attributes.CurrentMode,
clusters.ModeSelect.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -137,6 +231,8 @@ DISCOVERY_SCHEMAS = [
clusters.OvenMode.Attributes.CurrentMode,
clusters.OvenMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -149,6 +245,8 @@ DISCOVERY_SCHEMAS = [
clusters.LaundryWasherMode.Attributes.CurrentMode,
clusters.LaundryWasherMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -161,6 +259,8 @@ DISCOVERY_SCHEMAS = [
clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.CurrentMode,
clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -173,6 +273,8 @@ DISCOVERY_SCHEMAS = [
clusters.RvcCleanMode.Attributes.CurrentMode,
clusters.RvcCleanMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -185,6 +287,8 @@ DISCOVERY_SCHEMAS = [
clusters.DishwasherMode.Attributes.CurrentMode,
clusters.DishwasherMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -197,6 +301,8 @@ DISCOVERY_SCHEMAS = [
clusters.EnergyEvseMode.Attributes.CurrentMode,
clusters.EnergyEvseMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -209,6 +315,8 @@ DISCOVERY_SCHEMAS = [
clusters.DeviceEnergyManagementMode.Attributes.CurrentMode,
clusters.DeviceEnergyManagementMode.Attributes.SupportedModes,
),
+ # don't discover this entry if the supported modes list is empty
+ secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -230,8 +338,10 @@ DISCOVERY_SCHEMAS = [
"previous": None,
}.get,
),
- entity_class=MatterSelectEntity,
+ entity_class=MatterAttributeSelectEntity,
required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,),
+ # allow None value for previous state
+ allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.SELECT,
@@ -251,7 +361,78 @@ DISCOVERY_SCHEMAS = [
"low": 2,
}.get,
),
- entity_class=MatterSelectEntity,
+ entity_class=MatterAttributeSelectEntity,
required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,),
),
+ MatterDiscoverySchema(
+ platform=Platform.SELECT,
+ entity_description=MatterSelectEntityDescription(
+ key="TrvTemperatureDisplayMode",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="temperature_display_mode",
+ options=["Celsius", "Fahrenheit"],
+ measurement_to_ha={
+ 0: "Celsius",
+ 1: "Fahrenheit",
+ }.get,
+ ha_to_native_value={
+ "Celsius": 0,
+ "Fahrenheit": 1,
+ }.get,
+ ),
+ entity_class=MatterAttributeSelectEntity,
+ required_attributes=(
+ clusters.ThermostatUserInterfaceConfiguration.Attributes.TemperatureDisplayMode,
+ ),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SELECT,
+ entity_description=MatterListSelectEntityDescription(
+ key="TemperatureControlSelectedTemperatureLevel",
+ translation_key="temperature_level",
+ command=lambda selected_index: clusters.TemperatureControl.Commands.SetTemperature(
+ targetTemperatureLevel=selected_index
+ ),
+ list_attribute=clusters.TemperatureControl.Attributes.SupportedTemperatureLevels,
+ ),
+ entity_class=MatterListSelectEntity,
+ required_attributes=(
+ clusters.TemperatureControl.Attributes.SelectedTemperatureLevel,
+ clusters.TemperatureControl.Attributes.SupportedTemperatureLevels,
+ ),
+ # don't discover this entry if the supported levels list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SELECT,
+ entity_description=MatterListSelectEntityDescription(
+ key="LaundryWasherControlsSpinSpeed",
+ translation_key="laundry_washer_spin_speed",
+ list_attribute=clusters.LaundryWasherControls.Attributes.SpinSpeeds,
+ ),
+ entity_class=MatterListSelectEntity,
+ required_attributes=(
+ clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent,
+ clusters.LaundryWasherControls.Attributes.SpinSpeeds,
+ ),
+ # don't discover this entry if the spinspeeds list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SELECT,
+ entity_description=MatterMapSelectEntityDescription(
+ key="MatterLaundryWasherNumberOfRinses",
+ translation_key="laundry_washer_number_of_rinses",
+ list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses,
+ measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get,
+ ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get,
+ ),
+ entity_class=MatterMapSelectEntity,
+ required_attributes=(
+ clusters.LaundryWasherControls.Attributes.NumberOfRinses,
+ clusters.LaundryWasherControls.Attributes.SupportedRinses,
+ ),
+ # don't discover this entry if the supported rinses list is empty
+ secondary_value_is_not=[],
+ ),
]
diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py
index 847c9439b81..3503e112db5 100644
--- a/homeassistant/components/matter/sensor.py
+++ b/homeassistant/components/matter/sensor.py
@@ -7,8 +7,11 @@ from datetime import datetime
from typing import TYPE_CHECKING, cast
from chip.clusters import Objects as clusters
+from chip.clusters.ClusterObjects import ClusterAttributeDescriptor
from chip.clusters.Types import Nullable, NullValue
+from matter_server.client.models import device_types
from matter_server.common.custom_clusters import (
+ DraftElectricalMeasurementCluster,
EveCluster,
NeoCluster,
ThirdRealityMeteringCluster,
@@ -69,6 +72,9 @@ OPERATIONAL_STATE_MAP = {
clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running",
clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused",
clusters.OperationalState.Enums.OperationalStateEnum.kError: "error",
+ clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger",
+ clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging",
+ clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked",
}
@@ -87,6 +93,26 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip
"""Describe Matter sensor entities."""
+@dataclass(frozen=True, kw_only=True)
+class MatterListSensorEntityDescription(MatterSensorEntityDescription):
+ """Describe Matter sensor entities from MatterListSensor."""
+
+ # list attribute: the attribute descriptor to get the list of values (= list of strings)
+ list_attribute: type[ClusterAttributeDescriptor]
+
+
+@dataclass(frozen=True, kw_only=True)
+class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescription):
+ """Describe Matter sensor entities from Matter OperationalState objects."""
+
+ # list attribute: the attribute descriptor to get the list of values (= list of structs)
+ # needs to be set for handling OperationalState not on the OperationalState cluster, but
+ # on one of its derived clusters (e.g. RvcOperationalState)
+ state_list_attribute: type[ClusterAttributeDescriptor] = (
+ clusters.OperationalState.Attributes.OperationalStateList
+ )
+
+
class MatterSensor(MatterEntity, SensorEntity):
"""Representation of a Matter sensor."""
@@ -104,9 +130,39 @@ class MatterSensor(MatterEntity, SensorEntity):
self._attr_native_value = value
+class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity):
+ """Representation of a Matter sensor for Matter 1.0 draft ElectricalMeasurement cluster."""
+
+ entity_description: MatterSensorEntityDescription
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ raw_value: Nullable | float | None
+ divisor: Nullable | float | None
+ multiplier: Nullable | float | None
+
+ raw_value, divisor, multiplier = (
+ self.get_matter_attribute_value(self._entity_info.attributes_to_watch[0]),
+ self.get_matter_attribute_value(self._entity_info.attributes_to_watch[1]),
+ self.get_matter_attribute_value(self._entity_info.attributes_to_watch[2]),
+ )
+
+ for value in (divisor, multiplier):
+ if value in (None, NullValue, 0):
+ self._attr_native_value = None
+ return
+
+ if raw_value in (None, NullValue):
+ self._attr_native_value = None
+ else:
+ self._attr_native_value = round(raw_value / divisor * multiplier, 2)
+
+
class MatterOperationalStateSensor(MatterSensor):
"""Representation of a sensor for Matter Operational State."""
+ entity_description: MatterOperationalStateSensorEntityDescription
states_map: dict[int, str]
@callback
@@ -117,10 +173,11 @@ class MatterOperationalStateSensor(MatterSensor):
# therefore it is not possible to provide a fixed list of options
# or to provide a mapping to a translateable string for all options
operational_state_list = self.get_matter_attribute_value(
- clusters.OperationalState.Attributes.OperationalStateList
+ self.entity_description.state_list_attribute
)
if TYPE_CHECKING:
operational_state_list = cast(
+ # cast to the generic OperationalStateStruct type just to help typing
list[clusters.OperationalState.Structs.OperationalStateStruct],
operational_state_list,
)
@@ -140,6 +197,28 @@ class MatterOperationalStateSensor(MatterSensor):
)
+class MatterListSensor(MatterSensor):
+ """Representation of a sensor entity from Matter list from Cluster attribute(s)."""
+
+ entity_description: MatterListSensorEntityDescription
+ _attr_device_class = SensorDeviceClass.ENUM
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ self._attr_options = list_values = cast(
+ list[str],
+ self.get_matter_attribute_value(self.entity_description.list_attribute),
+ )
+ current_value: int = self.get_matter_attribute_value(
+ self._entity_info.primary_attribute
+ )
+ try:
+ self._attr_native_value = list_values[current_value]
+ except IndexError:
+ self._attr_native_value = None
+
+
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -244,6 +323,8 @@ DISCOVERY_SCHEMAS = [
required_attributes=(
clusters.PowerSource.Attributes.BatReplacementDescription,
),
+ # Some manufacturers returns an empty string
+ value_is_not="",
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
@@ -638,6 +719,60 @@ DISCOVERY_SCHEMAS = [
clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported,
),
),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ElectricalMeasurementActivePower",
+ device_class=SensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterDraftElectricalMeasurementSensor,
+ required_attributes=(
+ DraftElectricalMeasurementCluster.Attributes.ActivePower,
+ DraftElectricalMeasurementCluster.Attributes.AcPowerDivisor,
+ DraftElectricalMeasurementCluster.Attributes.AcPowerMultiplier,
+ ),
+ absent_clusters=(clusters.ElectricalPowerMeasurement,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ElectricalMeasurementRmsVoltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ suggested_display_precision=0,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterDraftElectricalMeasurementSensor,
+ required_attributes=(
+ DraftElectricalMeasurementCluster.Attributes.RmsVoltage,
+ DraftElectricalMeasurementCluster.Attributes.AcVoltageDivisor,
+ DraftElectricalMeasurementCluster.Attributes.AcVoltageMultiplier,
+ ),
+ absent_clusters=(clusters.ElectricalPowerMeasurement,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ElectricalMeasurementRmsCurrent",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterDraftElectricalMeasurementSensor,
+ required_attributes=(
+ DraftElectricalMeasurementCluster.Attributes.RmsCurrent,
+ DraftElectricalMeasurementCluster.Attributes.AcCurrentDivisor,
+ DraftElectricalMeasurementCluster.Attributes.AcCurrentMultiplier,
+ ),
+ absent_clusters=(clusters.ElectricalPowerMeasurement,),
+ ),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -664,7 +799,7 @@ DISCOVERY_SCHEMAS = [
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
- entity_description=MatterSensorEntityDescription(
+ entity_description=MatterOperationalStateSensorEntityDescription(
key="OperationalState",
device_class=SensorDeviceClass.ENUM,
translation_key="operational_state",
@@ -674,5 +809,99 @@ DISCOVERY_SCHEMAS = [
clusters.OperationalState.Attributes.OperationalState,
clusters.OperationalState.Attributes.OperationalStateList,
),
+ # don't discover this entry if the supported state list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterListSensorEntityDescription(
+ key="OperationalStateCurrentPhase",
+ translation_key="current_phase",
+ list_attribute=clusters.OperationalState.Attributes.PhaseList,
+ ),
+ entity_class=MatterListSensor,
+ required_attributes=(
+ clusters.OperationalState.Attributes.CurrentPhase,
+ clusters.OperationalState.Attributes.PhaseList,
+ ),
+ # don't discover this entry if the supported state list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterListSensorEntityDescription(
+ key="RvcOperationalStateCurrentPhase",
+ translation_key="current_phase",
+ list_attribute=clusters.RvcOperationalState.Attributes.PhaseList,
+ ),
+ entity_class=MatterListSensor,
+ required_attributes=(
+ clusters.RvcOperationalState.Attributes.CurrentPhase,
+ clusters.RvcOperationalState.Attributes.PhaseList,
+ ),
+ # don't discover this entry if the supported state list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterListSensorEntityDescription(
+ key="OvenCavityOperationalStateCurrentPhase",
+ translation_key="current_phase",
+ list_attribute=clusters.OvenCavityOperationalState.Attributes.PhaseList,
+ ),
+ entity_class=MatterListSensor,
+ required_attributes=(
+ clusters.OvenCavityOperationalState.Attributes.CurrentPhase,
+ clusters.OvenCavityOperationalState.Attributes.PhaseList,
+ ),
+ # don't discover this entry if the supported state list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ThermostatLocalTemperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ measurement_to_ha=lambda x: x / 100,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,),
+ device_type=(device_types.Thermostat,),
+ allow_multi=True, # also used for climate entity
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterOperationalStateSensorEntityDescription(
+ key="RvcOperationalState",
+ device_class=SensorDeviceClass.ENUM,
+ translation_key="operational_state",
+ state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList,
+ ),
+ entity_class=MatterOperationalStateSensor,
+ required_attributes=(
+ clusters.RvcOperationalState.Attributes.OperationalState,
+ clusters.RvcOperationalState.Attributes.OperationalStateList,
+ ),
+ allow_multi=True, # also used for vacuum entity
+ # don't discover this entry if the supported state list is empty
+ secondary_value_is_not=[],
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterOperationalStateSensorEntityDescription(
+ key="OvenCavityOperationalState",
+ device_class=SensorDeviceClass.ENUM,
+ translation_key="operational_state",
+ state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList,
+ ),
+ entity_class=MatterOperationalStateSensor,
+ required_attributes=(
+ clusters.OvenCavityOperationalState.Attributes.OperationalState,
+ clusters.OvenCavityOperationalState.Attributes.OperationalStateList,
+ ),
+ # don't discover this entry if the supported state list is empty
+ secondary_value_is_not=[],
),
]
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index ca15538997e..f1a123c61be 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -161,6 +161,9 @@
},
"altitude": {
"name": "Altitude above Sea Level"
+ },
+ "temperature_offset": {
+ "name": "Temperature offset"
}
},
"light": {
@@ -196,6 +199,24 @@
"toggle": "[%key:common::action::toggle%]",
"previous": "Previous"
}
+ },
+ "temperature_level": {
+ "name": "Temperature level"
+ },
+ "temperature_display_mode": {
+ "name": "Temperature display mode"
+ },
+ "laundry_washer_number_of_rinses": {
+ "name": "Number of rinses",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "normal": "Normal",
+ "extra": "Extra",
+ "max": "Max"
+ }
+ },
+ "laundry_washer_spin_speed": {
+ "name": "Spin speed"
}
},
"sensor": {
@@ -237,7 +258,10 @@
"stopped": "Stopped",
"running": "Running",
"paused": "[%key:common::state::paused%]",
- "error": "Error"
+ "error": "Error",
+ "seeking_charger": "Seeking charger",
+ "charging": "Charging",
+ "docked": "Docked"
}
},
"switch_current_position": {
@@ -248,6 +272,9 @@
},
"battery_replacement_description": {
"name": "Battery type"
+ },
+ "current_phase": {
+ "name": "Current phase"
}
},
"switch": {
@@ -256,6 +283,9 @@
},
"power": {
"name": "Power"
+ },
+ "child_lock": {
+ "name": "Child lock"
}
},
"vacuum": {
diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py
index 75269de953c..890ca662295 100644
--- a/homeassistant/components/matter/switch.py
+++ b/homeassistant/components/matter/switch.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any
from chip.clusters import Objects as clusters
@@ -13,11 +14,11 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import EntityCategory, Platform
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
@@ -39,18 +40,14 @@ class MatterSwitch(MatterEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn switch on."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=clusters.OnOff.Commands.On(),
+ await self.send_device_command(
+ clusters.OnOff.Commands.On(),
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn switch off."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=clusters.OnOff.Commands.Off(),
+ await self.send_device_command(
+ clusters.OnOff.Commands.Off(),
)
@callback
@@ -61,6 +58,43 @@ class MatterSwitch(MatterEntity, SwitchEntity):
)
+@dataclass(frozen=True)
+class MatterNumericSwitchEntityDescription(
+ SwitchEntityDescription, MatterEntityDescription
+):
+ """Describe Matter Numeric Switch entities."""
+
+
+class MatterNumericSwitch(MatterSwitch):
+ """Representation of a Matter Enum Attribute as a Switch entity."""
+
+ entity_description: MatterNumericSwitchEntityDescription
+
+ async def _async_set_native_value(self, value: bool) -> None:
+ """Update the current value."""
+ if value_convert := self.entity_description.ha_to_native_value:
+ send_value = value_convert(value)
+ await self.write_attribute(
+ value=send_value,
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn switch on."""
+ await self._async_set_native_value(True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn switch off."""
+ await self._async_set_native_value(False)
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
+ if value_convert := self.entity_description.measurement_to_ha:
+ value = value_convert(value)
+ self._attr_is_on = value
+
+
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -139,4 +173,25 @@ DISCOVERY_SCHEMAS = [
device_types.Speaker,
),
),
+ MatterDiscoverySchema(
+ platform=Platform.SWITCH,
+ entity_description=MatterNumericSwitchEntityDescription(
+ key="EveTrvChildLock",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="child_lock",
+ measurement_to_ha={
+ 0: False,
+ 1: True,
+ }.get,
+ ha_to_native_value={
+ False: 0,
+ True: 1,
+ }.get,
+ ),
+ entity_class=MatterNumericSwitch,
+ required_attributes=(
+ clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout,
+ ),
+ vendor_id=(4874,),
+ ),
]
diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py
index f31dd7b3aa3..5ee9b2e5fa0 100644
--- a/homeassistant/components/matter/update.py
+++ b/homeassistant/components/matter/update.py
@@ -261,5 +261,6 @@ DISCOVERY_SCHEMAS = [
clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState,
clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress,
),
+ allow_none_value=True,
),
]
diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py
index e98e1ad0bbd..de4a885d8fb 100644
--- a/homeassistant/components/matter/vacuum.py
+++ b/homeassistant/components/matter/vacuum.py
@@ -69,15 +69,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
- await self._send_device_command(clusters.OperationalState.Commands.Stop())
+ await self.send_device_command(clusters.OperationalState.Commands.Stop())
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
- await self._send_device_command(clusters.RvcOperationalState.Commands.GoHome())
+ await self.send_device_command(clusters.RvcOperationalState.Commands.GoHome())
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the vacuum cleaner."""
- await self._send_device_command(clusters.Identify.Commands.Identify())
+ await self.send_device_command(clusters.Identify.Commands.Identify())
async def async_start(self) -> None:
"""Start or resume the cleaning task."""
@@ -87,26 +87,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
clusters.RvcOperationalState.Commands.Resume.command_id
in self._last_accepted_commands
):
- await self._send_device_command(
+ await self.send_device_command(
clusters.RvcOperationalState.Commands.Resume()
)
else:
- await self._send_device_command(clusters.OperationalState.Commands.Start())
+ await self.send_device_command(clusters.OperationalState.Commands.Start())
async def async_pause(self) -> None:
"""Pause the cleaning task."""
- await self._send_device_command(clusters.OperationalState.Commands.Pause())
-
- async def _send_device_command(
- self,
- command: clusters.ClusterCommand,
- ) -> None:
- """Send a command to the device."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=command,
- )
+ await self.send_device_command(clusters.OperationalState.Commands.Pause())
@callback
def _update_from_device(self) -> None:
@@ -219,5 +208,6 @@ DISCOVERY_SCHEMAS = [
clusters.PowerSource.Attributes.BatPercentRemaining,
),
device_type=(device_types.RoboticVacuumCleaner,),
+ allow_none_value=True,
),
]
diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py
index ccb4e89da17..29946621853 100644
--- a/homeassistant/components/matter/valve.py
+++ b/homeassistant/components/matter/valve.py
@@ -42,17 +42,6 @@ class MatterValve(MatterEntity, ValveEntity):
entity_description: ValveEntityDescription
_platform_translation_key = "valve"
- async def send_device_command(
- self,
- command: clusters.ClusterCommand,
- ) -> None:
- """Send a command to the device."""
- await self.matter_client.send_device_command(
- node_id=self._endpoint.node.node_id,
- endpoint_id=self._endpoint.endpoint_id,
- command=command,
- )
-
async def async_open_valve(self) -> None:
"""Open the valve."""
await self.send_device_command(ValveConfigurationAndControl.Commands.Open())
diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py
index d4a3a45f441..4e79a00fed0 100644
--- a/homeassistant/components/maxcube/__init__.py
+++ b/homeassistant/components/maxcube/__init__.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.dt import now
diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py
new file mode 100644
index 00000000000..4a2b4da990d
--- /dev/null
+++ b/homeassistant/components/mcp/__init__.py
@@ -0,0 +1,69 @@
+"""The Model Context Protocol integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import llm
+
+from .const import DOMAIN
+from .coordinator import ModelContextProtocolCoordinator
+from .types import ModelContextProtocolConfigEntry
+
+__all__ = [
+ "DOMAIN",
+ "async_setup_entry",
+ "async_unload_entry",
+]
+
+API_PROMPT = "The following tools are available from a remote server named {name}."
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
+) -> bool:
+ """Set up Model Context Protocol from a config entry."""
+ coordinator = ModelContextProtocolCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ unsub = llm.async_register_api(
+ hass,
+ ModelContextProtocolAPI(
+ hass=hass,
+ id=f"{DOMAIN}-{entry.entry_id}",
+ name=entry.title,
+ coordinator=coordinator,
+ ),
+ )
+ entry.async_on_unload(unsub)
+
+ entry.runtime_data = coordinator
+ entry.async_on_unload(coordinator.close)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return True
+
+
+@dataclass(kw_only=True)
+class ModelContextProtocolAPI(llm.API):
+ """Define an object to hold the Model Context Protocol API."""
+
+ coordinator: ModelContextProtocolCoordinator
+
+ async def async_get_api_instance(
+ self, llm_context: llm.LLMContext
+ ) -> llm.APIInstance:
+ """Return the instance of the API."""
+ return llm.APIInstance(
+ self,
+ API_PROMPT.format(name=self.name),
+ llm_context,
+ tools=self.coordinator.data,
+ )
diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py
new file mode 100644
index 00000000000..92e0052c665
--- /dev/null
+++ b/homeassistant/components/mcp/config_flow.py
@@ -0,0 +1,111 @@
+"""Config flow for the Model Context Protocol integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import httpx
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN
+from .coordinator import mcp_client
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL): str,
+ }
+)
+
+
+async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
+ """Validate the user input and connect to the MCP server."""
+ url = data[CONF_URL]
+ try:
+ cv.url(url) # Cannot be added to schema directly
+ except vol.Invalid as error:
+ raise InvalidUrl from error
+ try:
+ async with mcp_client(url) as session:
+ response = await session.initialize()
+ except httpx.TimeoutException as error:
+ _LOGGER.info("Timeout connecting to MCP server: %s", error)
+ raise TimeoutConnectError from error
+ except httpx.HTTPStatusError as error:
+ _LOGGER.info("Cannot connect to MCP server: %s", error)
+ if error.response.status_code == 401:
+ raise InvalidAuth from error
+ raise CannotConnect from error
+ except httpx.HTTPError as error:
+ _LOGGER.info("Cannot connect to MCP server: %s", error)
+ raise CannotConnect from error
+
+ if not response.capabilities.tools:
+ raise MissingCapabilities(
+ f"MCP Server {url} does not support 'Tools' capability"
+ )
+
+ return {"title": response.serverInfo.name}
+
+
+class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Model Context Protocol."""
+
+ 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:
+ info = await validate_input(self.hass, user_input)
+ except InvalidUrl:
+ errors[CONF_URL] = "invalid_url"
+ except TimeoutConnectError:
+ errors["base"] = "timeout_connect"
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ return self.async_abort(reason="invalid_auth")
+ except MissingCapabilities:
+ return self.async_abort(reason="missing_capabilities")
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
+ 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
+ )
+
+
+class InvalidUrl(HomeAssistantError):
+ """Error to indicate the URL format is invalid."""
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class TimeoutConnectError(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate there is invalid auth."""
+
+
+class MissingCapabilities(HomeAssistantError):
+ """Error to indicate that the MCP server is missing required capabilities."""
diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py
new file mode 100644
index 00000000000..675b2d7031c
--- /dev/null
+++ b/homeassistant/components/mcp/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Model Context Protocol integration."""
+
+DOMAIN = "mcp"
diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py
new file mode 100644
index 00000000000..a5c5ee55dbf
--- /dev/null
+++ b/homeassistant/components/mcp/coordinator.py
@@ -0,0 +1,171 @@
+"""Types for the Model Context Protocol integration."""
+
+import asyncio
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+import datetime
+import logging
+
+import httpx
+from mcp.client.session import ClientSession
+from mcp.client.sse import sse_client
+import voluptuous as vol
+from voluptuous_openapi import convert_to_voluptuous
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import llm
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util.json import JsonObjectType
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+UPDATE_INTERVAL = datetime.timedelta(minutes=30)
+TIMEOUT = 10
+
+
+@asynccontextmanager
+async def mcp_client(url: str) -> AsyncGenerator[ClientSession]:
+ """Create a server-sent event MCP client.
+
+ This is an asynccontext manager that exists to wrap other async context managers
+ so that the coordinator has a single object to manage.
+ """
+ try:
+ async with sse_client(url=url) as streams, ClientSession(*streams) as session:
+ await session.initialize()
+ yield session
+ except ExceptionGroup as err:
+ raise err.exceptions[0] from err
+
+
+class ModelContextProtocolTool(llm.Tool):
+ """A Tool exposed over the Model Context Protocol."""
+
+ def __init__(
+ self,
+ name: str,
+ description: str | None,
+ parameters: vol.Schema,
+ session: ClientSession,
+ ) -> None:
+ """Initialize the tool."""
+ self.name = name
+ self.description = description
+ self.parameters = parameters
+ self.session = session
+
+ async def async_call(
+ self,
+ hass: HomeAssistant,
+ tool_input: llm.ToolInput,
+ llm_context: llm.LLMContext,
+ ) -> JsonObjectType:
+ """Call the tool."""
+ try:
+ result = await self.session.call_tool(
+ tool_input.tool_name, tool_input.tool_args
+ )
+ except httpx.HTTPStatusError as error:
+ raise HomeAssistantError(f"Error when calling tool: {error}") from error
+ return result.model_dump(exclude_unset=True, exclude_none=True)
+
+
+class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
+ """Define an object to hold MCP data."""
+
+ config_entry: ConfigEntry
+ _session: ClientSession | None = None
+ _setup_error: Exception | None = None
+
+ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ """Initialize ModelContextProtocolCoordinator."""
+ super().__init__(
+ hass,
+ logger=_LOGGER,
+ name=DOMAIN,
+ config_entry=config_entry,
+ update_interval=UPDATE_INTERVAL,
+ )
+ self._stop = asyncio.Event()
+
+ async def _async_setup(self) -> None:
+ """Set up the client connection."""
+ connected = asyncio.Event()
+ stop = asyncio.Event()
+ self.config_entry.async_create_background_task(
+ self.hass, self._connect(connected, stop), "mcp-client"
+ )
+ try:
+ async with asyncio.timeout(TIMEOUT):
+ await connected.wait()
+ self._stop = stop
+ finally:
+ if self._setup_error is not None:
+ raise self._setup_error
+
+ async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None:
+ """Create a server-sent event MCP client."""
+ url = self.config_entry.data[CONF_URL]
+ try:
+ async with (
+ sse_client(url=url) as streams,
+ ClientSession(*streams) as session,
+ ):
+ await session.initialize()
+ self._session = session
+ connected.set()
+ await stop.wait()
+ except httpx.HTTPStatusError as err:
+ self._setup_error = err
+ _LOGGER.debug("Error connecting to MCP server: %s", err)
+ raise UpdateFailed(f"Error connecting to MCP server: {err}") from err
+ except ExceptionGroup as err:
+ self._setup_error = err.exceptions[0]
+ _LOGGER.debug("Error connecting to MCP server: %s", err)
+ raise UpdateFailed(
+ "Error connecting to MCP server: {err.exceptions[0]}"
+ ) from err.exceptions[0]
+ finally:
+ self._session = None
+
+ async def close(self) -> None:
+ """Close the client connection."""
+ if self._stop is not None:
+ self._stop.set()
+
+ async def _async_update_data(self) -> list[llm.Tool]:
+ """Fetch data from API endpoint.
+
+ This is the place to pre-process the data to lookup tables
+ so entities can quickly look up their data.
+ """
+ if self._session is None:
+ raise UpdateFailed("No session available")
+ try:
+ result = await self._session.list_tools()
+ except httpx.HTTPError as err:
+ raise UpdateFailed(f"Error communicating with API: {err}") from err
+
+ _LOGGER.debug("Received tools: %s", result.tools)
+ tools: list[llm.Tool] = []
+ for tool in result.tools:
+ try:
+ parameters = convert_to_voluptuous(tool.inputSchema)
+ except Exception as err:
+ raise UpdateFailed(
+ f"Error converting schema {err}: {tool.inputSchema}"
+ ) from err
+ tools.append(
+ ModelContextProtocolTool(
+ tool.name,
+ tool.description,
+ parameters,
+ self._session,
+ )
+ )
+ return tools
diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json
new file mode 100644
index 00000000000..ee4baf04802
--- /dev/null
+++ b/homeassistant/components/mcp/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mcp",
+ "name": "Model Context Protocol",
+ "codeowners": ["@allenporter"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/mcp",
+ "iot_class": "local_polling",
+ "quality_scale": "silver",
+ "requirements": ["mcp==1.1.2"]
+}
diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml
new file mode 100644
index 00000000000..76afdf5860d
--- /dev/null
+++ b/homeassistant/components/mcp/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not have actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Integration does not have actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: Integration does not have entities.
+ entity-unique-id:
+ status: exempt
+ comment: Integration does not have entities.
+ has-entity-name:
+ status: exempt
+ comment: Integration does not have entities.
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: Integration does not have actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: Integration does not have entities.
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: Integration does not have platforms.
+ reauthentication-flow:
+ status: exempt
+ comment: Integration does not support authentication.
+ test-coverage: done
+
+ # Gold
+ devices:
+ status: exempt
+ comment: Integration does not have devices.
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category:
+ status: exempt
+ comment: Integration does not have entities.
+ entity-device-class:
+ status: exempt
+ comment: Integration does not have entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: Integration does not have entities.
+ entity-translations:
+ status: exempt
+ comment: Integration does not have entities.
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json
new file mode 100644
index 00000000000..97a75fc6f85
--- /dev/null
+++ b/homeassistant/components/mcp/strings.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]"
+ },
+ "data_description": {
+ "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse"
+ },
+ "abort": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "missing_capabilities": "The MCP server does not support a required capability (Tools)",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/mcp/types.py b/homeassistant/components/mcp/types.py
new file mode 100644
index 00000000000..961c9ab3d18
--- /dev/null
+++ b/homeassistant/components/mcp/types.py
@@ -0,0 +1,7 @@
+"""Types for the Model Context Protocol integration."""
+
+from homeassistant.config_entries import ConfigEntry
+
+from .coordinator import ModelContextProtocolCoordinator
+
+type ModelContextProtocolConfigEntry = ConfigEntry[ModelContextProtocolCoordinator]
diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py
new file mode 100644
index 00000000000..941eccbe528
--- /dev/null
+++ b/homeassistant/components/mcp_server/__init__.py
@@ -0,0 +1,44 @@
+"""The Model Context Protocol Server integration."""
+
+from __future__ import annotations
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+from . import http, llm_api
+from .const import DOMAIN
+from .session import SessionManager
+from .types import MCPServerConfigEntry
+
+__all__ = [
+ "CONFIG_SCHEMA",
+ "DOMAIN",
+ "async_setup",
+ "async_setup_entry",
+ "async_unload_entry",
+]
+
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Model Context Protocol component."""
+ http.async_register(hass)
+ llm_api.async_register_api(hass)
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: MCPServerConfigEntry) -> bool:
+ """Set up Model Context Protocol Server from a config entry."""
+
+ entry.runtime_data = SessionManager()
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: MCPServerConfigEntry) -> bool:
+ """Unload a config entry."""
+ session_manager = entry.runtime_data
+ session_manager.close()
+ return True
diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py
new file mode 100644
index 00000000000..8d8d311b874
--- /dev/null
+++ b/homeassistant/components/mcp_server/config_flow.py
@@ -0,0 +1,69 @@
+"""Config flow for the Model Context Protocol Server integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_LLM_HASS_API
+from homeassistant.helpers import llm
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+)
+
+from .const import DOMAIN, LLM_API, LLM_API_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+MORE_INFO_URL = "https://www.home-assistant.io/integrations/mcp_server/#configuration"
+
+
+class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Model Context Protocol Server."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)}
+ if LLM_API not in llm_apis:
+ # MCP server component is not loaded yet, so make the LLM API a choice.
+ llm_apis = {
+ LLM_API: LLM_API_NAME,
+ **llm_apis,
+ }
+
+ if user_input is not None:
+ return self.async_create_entry(
+ title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_LLM_HASS_API,
+ default=llm.LLM_API_ASSIST,
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(
+ label=name,
+ value=llm_api_id,
+ )
+ for llm_api_id, name in llm_apis.items()
+ ]
+ )
+ ),
+ }
+ ),
+ description_placeholders={"more_info_url": MORE_INFO_URL},
+ )
diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py
new file mode 100644
index 00000000000..8958ac36616
--- /dev/null
+++ b/homeassistant/components/mcp_server/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Model Context Protocol Server integration."""
+
+DOMAIN = "mcp_server"
+TITLE = "Model Context Protocol Server"
+LLM_API = "stateless_assist"
+LLM_API_NAME = "Stateless Assist"
diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py
new file mode 100644
index 00000000000..433d978cef7
--- /dev/null
+++ b/homeassistant/components/mcp_server/http.py
@@ -0,0 +1,170 @@
+"""Model Context Protocol transport protocol for Server Sent Events (SSE).
+
+This registers HTTP endpoints that supports SSE as a transport layer
+for the Model Context Protocol. There are two HTTP endpoints:
+
+- /mcp_server/sse: The SSE endpoint that is used to establish a session
+ with the client and glue to the MCP server. This is used to push responses
+ to the client.
+- /mcp_server/messages: The endpoint that is used by the client to send
+ POST requests with new requests for the MCP server. The request contains
+ a session identifier. The response to the client is passed over the SSE
+ session started on the other endpoint.
+
+See https://modelcontextprotocol.io/docs/concepts/transports
+"""
+
+import logging
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPBadRequest, HTTPNotFound
+from aiohttp_sse import sse_response
+import anyio
+from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
+from mcp import types
+
+from homeassistant.components import conversation
+from homeassistant.components.http import KEY_HASS, HomeAssistantView
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_LLM_HASS_API
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import llm
+
+from .const import DOMAIN
+from .server import create_server
+from .session import Session
+from .types import MCPServerConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+SSE_API = f"/{DOMAIN}/sse"
+MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
+
+
+@callback
+def async_register(hass: HomeAssistant) -> None:
+ """Register the websocket API."""
+ hass.http.register_view(ModelContextProtocolSSEView())
+ hass.http.register_view(ModelContextProtocolMessagesView())
+
+
+def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
+ """Get the first enabled MCP server config entry.
+
+ The ConfigEntry contains a reference to the actual MCP server used to
+ serve the Model Context Protocol.
+
+ Will raise an HTTP error if the expected configuration is not present.
+ """
+ config_entries: list[MCPServerConfigEntry] = [
+ config_entry
+ for config_entry in hass.config_entries.async_entries(DOMAIN)
+ if config_entry.state == ConfigEntryState.LOADED
+ ]
+ if not config_entries:
+ raise HTTPNotFound(text="Model Context Protocol server is not configured")
+ if len(config_entries) > 1:
+ raise HTTPNotFound(text="Found multiple Model Context Protocol configurations")
+ return config_entries[0]
+
+
+class ModelContextProtocolSSEView(HomeAssistantView):
+ """Model Context Protocol SSE endpoint."""
+
+ name = f"{DOMAIN}:sse"
+ url = SSE_API
+
+ async def get(self, request: web.Request) -> web.StreamResponse:
+ """Process SSE messages for the Model Context Protocol.
+
+ This is a long running request for the lifetime of the client session
+ and is the primary transport layer between the client and server.
+
+ Pairs of buffered streams act as a bridge between the transport protocol
+ (SSE over HTTP views) and the Model Context Protocol. The MCP SDK
+ manages all protocol details and invokes commands on our MCP server.
+ """
+ hass = request.app[KEY_HASS]
+ entry = async_get_config_entry(hass)
+ session_manager = entry.runtime_data
+
+ context = llm.LLMContext(
+ platform=DOMAIN,
+ context=self.context(request),
+ user_prompt=None,
+ language="*",
+ assistant=conversation.DOMAIN,
+ device_id=None,
+ )
+ llm_api_id = entry.data[CONF_LLM_HASS_API]
+ server = await create_server(hass, llm_api_id, context)
+ options = await hass.async_add_executor_job(
+ server.create_initialization_options # Reads package for version info
+ )
+
+ read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception]
+ read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception]
+ read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
+
+ write_stream: MemoryObjectSendStream[types.JSONRPCMessage]
+ write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage]
+ write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
+
+ async with (
+ sse_response(request) as response,
+ session_manager.create(Session(read_stream_writer)) as session_id,
+ ):
+ session_uri = MESSAGES_API.format(session_id=session_id)
+ _LOGGER.debug("Sending SSE endpoint: %s", session_uri)
+ await response.send(session_uri, event="endpoint")
+
+ async def sse_reader() -> None:
+ """Forward MCP server responses to the client."""
+ async for message in write_stream_reader:
+ _LOGGER.debug("Sending SSE message: %s", message)
+ await response.send(
+ message.model_dump_json(by_alias=True, exclude_none=True),
+ event="message",
+ )
+
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(sse_reader)
+ await server.run(read_stream, write_stream, options)
+ return response
+
+
+class ModelContextProtocolMessagesView(HomeAssistantView):
+ """Model Context Protocol messages endpoint."""
+
+ name = f"{DOMAIN}:messages"
+ url = MESSAGES_API
+
+ async def post(
+ self,
+ request: web.Request,
+ session_id: str,
+ ) -> web.StreamResponse:
+ """Process incoming messages for the Model Context Protocol.
+
+ The request passes a session ID which is used to identify the original
+ SSE connection. This view parses incoming messages from the transport
+ layer then writes them to the MCP server stream for the session.
+ """
+ hass = request.app[KEY_HASS]
+ config_entry = async_get_config_entry(hass)
+
+ session_manager = config_entry.runtime_data
+ if (session := session_manager.get(session_id)) is None:
+ _LOGGER.info("Could not find session ID: '%s'", session_id)
+ raise HTTPNotFound(text=f"Could not find session ID '{session_id}'")
+
+ json_data = await request.json()
+ try:
+ message = types.JSONRPCMessage.model_validate(json_data)
+ except ValueError as err:
+ _LOGGER.info("Failed to parse message: %s", err)
+ raise HTTPBadRequest(text="Could not parse message") from err
+
+ _LOGGER.debug("Received client message: %s", message)
+ await session.read_stream_writer.send(message)
+ return web.Response(status=200)
diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py
new file mode 100644
index 00000000000..5c29b29153e
--- /dev/null
+++ b/homeassistant/components/mcp_server/llm_api.py
@@ -0,0 +1,48 @@
+"""LLM API for MCP Server."""
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import llm
+from homeassistant.util import yaml as yaml_util
+
+from .const import LLM_API, LLM_API_NAME
+
+EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"}
+
+
+def async_register_api(hass: HomeAssistant) -> None:
+ """Register the LLM API."""
+ llm.async_register_api(hass, StatelessAssistAPI(hass))
+
+
+class StatelessAssistAPI(llm.AssistAPI):
+ """LLM API for MCP Server that provides the Assist API without state information in the prompt.
+
+ Syncing the state information is possible, but may put unnecessary load on
+ the system so we are instead providing the prompt without entity state. Since
+ actions don't care about the current state, there is little quality loss.
+ """
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the StatelessAssistAPI."""
+ super().__init__(hass)
+ self.id = LLM_API
+ self.name = LLM_API_NAME
+
+ @callback
+ def _async_get_exposed_entities_prompt(
+ self, llm_context: llm.LLMContext, exposed_entities: dict | None
+ ) -> list[str]:
+ """Return the prompt for the exposed entities."""
+ prompt = []
+
+ if exposed_entities and exposed_entities["entities"]:
+ prompt.append(
+ "An overview of the areas and the devices in this smart home:"
+ )
+ entities = [
+ {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS}
+ for entity_info in exposed_entities["entities"].values()
+ ]
+ prompt.append(yaml_util.dump(list(entities)))
+
+ return prompt
diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json
new file mode 100644
index 00000000000..18b2e5bc417
--- /dev/null
+++ b/homeassistant/components/mcp_server/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "mcp_server",
+ "name": "Model Context Protocol Server",
+ "codeowners": ["@allenporter"],
+ "config_flow": true,
+ "dependencies": ["homeassistant", "http", "conversation"],
+ "documentation": "https://www.home-assistant.io/integrations/mcp_server",
+ "integration_type": "service",
+ "iot_class": "local_push",
+ "quality_scale": "silver",
+ "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"],
+ "single_config_entry": true
+}
diff --git a/homeassistant/components/mcp_server/quality_scale.yaml b/homeassistant/components/mcp_server/quality_scale.yaml
new file mode 100644
index 00000000000..546b4147285
--- /dev/null
+++ b/homeassistant/components/mcp_server/quality_scale.yaml
@@ -0,0 +1,118 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Service does not register actions
+ appropriate-polling:
+ status: exempt
+ comment: Service is not polling
+ brands: done
+ common-modules:
+ status: exempt
+ comment: Service does not have entities or coordinators
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Service does not register actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: Service does not subscribe to events
+ entity-unique-id:
+ status: exempt
+ comment: Service does not have entities
+ has-entity-name:
+ status: exempt
+ comment: Service does not have entities
+ runtime-data:
+ status: exempt
+ comment: No configuration state is used by the integration
+ test-before-configure:
+ status: exempt
+ comment: Service does not a connection
+ test-before-setup:
+ status: exempt
+ comment: Service does not a connection
+ unique-config-entry:
+ status: done
+ comment: Integration requires a single config entry.
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: Service does not register actions
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: Service does not have entities
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: Service does not have entities
+ parallel-updates:
+ status: exempt
+ comment: Service does not have entities
+ reauthentication-flow:
+ status: exempt
+ comment: Service does not require authentication
+ test-coverage: done
+
+ # Gold
+ devices:
+ status: exempt
+ comment: Service does not have entities
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: Service does not support discovery
+ discovery:
+ status: exempt
+ comment: Service does not support discovery
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: Service does not support devices
+ entity-category:
+ status: exempt
+ comment: Service does not have entities
+ entity-device-class:
+ status: exempt
+ comment: Service does not have entities
+ entity-disabled-by-default:
+ status: exempt
+ comment: Service does not have entities
+ entity-translations:
+ status: exempt
+ comment: Service does not have entities
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: Service does not have entities
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: Service does not have anything to repair
+ stale-devices:
+ status: exempt
+ comment: Service does not have devices
+
+ # Platinum
+ async-dependency:
+ status: exempt
+ comment: Service does not communicate with devices
+ inject-websession:
+ status: exempt
+ comment: Service does not communicate with devices
+ strict-typing: done
diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py
new file mode 100644
index 00000000000..ba21abd722c
--- /dev/null
+++ b/homeassistant/components/mcp_server/server.py
@@ -0,0 +1,108 @@
+"""The Model Context Protocol Server implementation.
+
+The Model Context Protocol python sdk defines a Server API that provides the
+MCP message handling logic and error handling. The server implementation provided
+here is independent of the lower level transport protocol.
+
+See https://modelcontextprotocol.io/docs/concepts/architecture#implementation-example
+"""
+
+from collections.abc import Callable, Sequence
+import json
+import logging
+from typing import Any
+
+from mcp import types
+from mcp.server import Server
+import voluptuous as vol
+from voluptuous_openapi import convert
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import llm
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _format_tool(
+ tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
+) -> types.Tool:
+ """Format tool specification."""
+ input_schema = convert(tool.parameters, custom_serializer=custom_serializer)
+ return types.Tool(
+ name=tool.name,
+ description=tool.description or "",
+ inputSchema={
+ "type": "object",
+ "properties": input_schema["properties"],
+ },
+ )
+
+
+async def create_server(
+ hass: HomeAssistant, llm_api_id: str, llm_context: llm.LLMContext
+) -> Server:
+ """Create a new Model Context Protocol Server.
+
+ A Model Context Protocol Server object is associated with a single session.
+ The MCP SDK handles the details of the protocol.
+ """
+
+ server = Server("home-assistant")
+
+ @server.list_prompts() # type: ignore[no-untyped-call, misc]
+ async def handle_list_prompts() -> list[types.Prompt]:
+ llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ return [
+ types.Prompt(
+ name=llm_api.api.name,
+ description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}",
+ )
+ ]
+
+ @server.get_prompt() # type: ignore[no-untyped-call, misc]
+ async def handle_get_prompt(
+ name: str, arguments: dict[str, str] | None
+ ) -> types.GetPromptResult:
+ llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ if name != llm_api.api.name:
+ raise ValueError(f"Unknown prompt: {name}")
+
+ return types.GetPromptResult(
+ description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}",
+ messages=[
+ types.PromptMessage(
+ role="assistant",
+ content=types.TextContent(
+ type="text",
+ text=llm_api.api_prompt,
+ ),
+ )
+ ],
+ )
+
+ @server.list_tools() # type: ignore[no-untyped-call, misc]
+ async def list_tools() -> list[types.Tool]:
+ """List available time tools."""
+ llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
+
+ @server.call_tool() # type: ignore[no-untyped-call, misc]
+ async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]:
+ """Handle calling tools."""
+ llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ tool_input = llm.ToolInput(tool_name=name, tool_args=arguments)
+ _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:
+ raise HomeAssistantError(f"Error calling tool: {e}") from e
+ return [
+ types.TextContent(
+ type="text",
+ text=json.dumps(tool_response),
+ )
+ ]
+
+ return server
diff --git a/homeassistant/components/mcp_server/session.py b/homeassistant/components/mcp_server/session.py
new file mode 100644
index 00000000000..4c586fd32a0
--- /dev/null
+++ b/homeassistant/components/mcp_server/session.py
@@ -0,0 +1,60 @@
+"""Model Context Protocol sessions.
+
+A session is a long-lived connection between the client and server that is used
+to exchange messages. The server pushes messages to the client over the session
+and the client sends messages to the server over the session.
+"""
+
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+import logging
+
+from anyio.streams.memory import MemoryObjectSendStream
+from mcp import types
+
+from homeassistant.util import ulid as ulid_util
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class Session:
+ """A session for the Model Context Protocol."""
+
+ read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception]
+
+
+class SessionManager:
+ """Manage SSE sessions for the MCP transport layer.
+
+ This class is used to manage the lifecycle of SSE sessions. It is responsible for
+ creating new sessions, resuming existing sessions, and closing sessions.
+ """
+
+ def __init__(self) -> None:
+ """Initialize the SSE server transport."""
+ self._sessions: dict[str, Session] = {}
+
+ @asynccontextmanager
+ async def create(self, session: Session) -> AsyncGenerator[str]:
+ """Context manager to create a new session ID and close when done."""
+ session_id = ulid_util.ulid_now()
+ _LOGGER.debug("Creating session: %s", session_id)
+ self._sessions[session_id] = session
+ try:
+ yield session_id
+ finally:
+ _LOGGER.debug("Closing session: %s", session_id)
+ if session_id in self._sessions: # close() may have already been called
+ self._sessions.pop(session_id)
+
+ def get(self, session_id: str) -> Session | None:
+ """Get an existing session."""
+ return self._sessions.get(session_id)
+
+ def close(self) -> None:
+ """Close any open sessions."""
+ for session in self._sessions.values():
+ session.read_stream_writer.close()
+ self._sessions.clear()
diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json
new file mode 100644
index 00000000000..fbd14038ddc
--- /dev/null
+++ b/homeassistant/components/mcp_server/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "See the [integration documentation]({more_info_url}) for setup instructions.",
+ "data": {
+ "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]"
+ },
+ "data_description": {
+ "llm_hass_api": "The method for controling Home Assistant to expose with the Model Context Protocol."
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/mcp_server/types.py b/homeassistant/components/mcp_server/types.py
new file mode 100644
index 00000000000..56ce0469e25
--- /dev/null
+++ b/homeassistant/components/mcp_server/types.py
@@ -0,0 +1,7 @@
+"""Types for the MCP server integration."""
+
+from homeassistant.config_entries import ConfigEntry
+
+from .session import SessionManager
+
+type MCPServerConfigEntry = ConfigEntry[SessionManager]
diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py
index 7d4f23d706e..cf8dfb5bc90 100644
--- a/homeassistant/components/mealie/coordinator.py
+++ b/homeassistant/components/mealie/coordinator.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index becca8e6da8..f0f8ee03ad0 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[default]==2025.01.15"],
+ "requirements": ["yt-dlp[default]==2025.01.26"],
"single_config_entry": true
}
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index 291b1ec1e2a..e109b0418c9 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -21,7 +21,7 @@ import aiohttp
from aiohttp import web
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
from aiohttp.typedefs import LooseHeaders
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from yarl import URL
@@ -780,7 +780,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
- if type(features) is int: # noqa: E721
+ if type(features) is int:
new_features = MediaPlayerEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json
index 1c9ba929b38..be06ae22cdc 100644
--- a/homeassistant/components/media_player/strings.json
+++ b/homeassistant/components/media_player/strings.json
@@ -310,7 +310,7 @@
"fields": {
"group_members": {
"name": "Group members",
- "description": "The players which will be synced with the playback specified in `target`."
+ "description": "The players which will be synced with the playback specified in 'Targets'."
}
}
},
diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py
index 3ea8f581245..5c6165a3477 100644
--- a/homeassistant/components/media_source/__init__.py
+++ b/homeassistant/components/media_source/__init__.py
@@ -38,18 +38,18 @@ from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
__all__ = [
"DOMAIN",
- "is_media_source_id",
- "generate_media_source_id",
- "async_browse_media",
- "async_resolve_media",
- "BrowseMediaSource",
- "PlayMedia",
- "MediaSourceItem",
- "Unresolvable",
- "MediaSource",
- "MediaSourceError",
"MEDIA_CLASS_MAP",
"MEDIA_MIME_TYPES",
+ "BrowseMediaSource",
+ "MediaSource",
+ "MediaSourceError",
+ "MediaSourceItem",
+ "PlayMedia",
+ "Unresolvable",
+ "async_browse_media",
+ "async_resolve_media",
+ "generate_media_source_id",
+ "is_media_source_id",
]
diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py
index 482ed0e855f..53bd8213262 100644
--- a/homeassistant/components/media_source/models.py
+++ b/homeassistant/components/media_source/models.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from abc import ABC
from dataclasses import dataclass
from typing import Any, cast
@@ -102,7 +101,7 @@ class MediaSourceItem:
return cls(hass, domain, identifier, target_media_player)
-class MediaSource(ABC):
+class MediaSource:
"""Represents a source of media files."""
name: str | None = None
diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py
index 97b61da437a..4561c38ce80 100644
--- a/homeassistant/components/mediaroom/media_player.py
+++ b/homeassistant/components/mediaroom/media_player.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py
index b604ee5016e..d2c9d67f29a 100644
--- a/homeassistant/components/melcloud/config_flow.py
+++ b/homeassistant/components/melcloud/config_flow.py
@@ -126,9 +126,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
)
- or isinstance(err, AttributeError)
- and err.name == "get"
- ):
+ ) or (isinstance(err, AttributeError) and err.name == "get"):
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
@@ -165,9 +163,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
)
- or isinstance(err, AttributeError)
- and err.name == "get"
- ):
+ ) or (isinstance(err, AttributeError) and err.name == "get"):
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py
index 0eb3742a878..70995fc69b5 100644
--- a/homeassistant/components/meraki/device_tracker.py
+++ b/homeassistant/components/meraki/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
CONF_VALIDATOR = "validator"
diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py
index 6da0e8176ef..c5cbe695243 100644
--- a/homeassistant/components/message_bird/notify.py
+++ b/homeassistant/components/message_bird/notify.py
@@ -15,7 +15,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_API_KEY, CONF_SENDER
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py
index 62964d22bb1..e5db80b2997 100644
--- a/homeassistant/components/met/config_flow.py
+++ b/homeassistant/components/met/config_flow.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfLength,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py
index ab2695cbd11..01917707bf7 100644
--- a/homeassistant/components/met_eireann/__init__.py
+++ b/homeassistant/components/met_eireann/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, P
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py
index 422b46827da..761d0655237 100644
--- a/homeassistant/components/met_eireann/config_flow.py
+++ b/homeassistant/components/met_eireann/config_flow.py
@@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, HOME_LOCATION_NAME
diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py
index 4b79b046b75..5c4ada6b5f1 100644
--- a/homeassistant/components/meteo_france/__init__.py
+++ b/homeassistant/components/meteo_france/__init__.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py
index 3400ca52f50..95124445363 100644
--- a/homeassistant/components/meteoalarm/binary_sensor.py
+++ b/homeassistant/components/meteoalarm/binary_sensor.py
@@ -15,10 +15,10 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py
index b93cc669e62..f666e2d614a 100644
--- a/homeassistant/components/mfi/sensor.py
+++ b/homeassistant/components/mfi/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py
index 833a2c21301..2a05018f301 100644
--- a/homeassistant/components/mfi/switch.py
+++ b/homeassistant/components/mfi/switch.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py
index aa33072089f..b5e770601b4 100644
--- a/homeassistant/components/microsoft/tts.py
+++ b/homeassistant/components/microsoft/tts.py
@@ -13,7 +13,7 @@ from homeassistant.components.tts import (
)
from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE
from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
CONF_GENDER = "gender"
CONF_OUTPUT = "output"
diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py
index fa4de7f9c99..23c9885e0c5 100644
--- a/homeassistant/components/microsoft_face/__init__.py
+++ b/homeassistant/components/microsoft_face/__init__.py
@@ -16,8 +16,8 @@ from homeassistant.components import camera
from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_TIMEOUT, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py
index 80037a29fa8..ce49f0b1f65 100644
--- a/homeassistant/components/microsoft_face_detect/image_processing.py
+++ b/homeassistant/components/microsoft_face_detect/image_processing.py
@@ -17,7 +17,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py
index 03a6ad22fcd..025a7eccdda 100644
--- a/homeassistant/components/microsoft_face_identify/image_processing.py
+++ b/homeassistant/components/microsoft_face_identify/image_processing.py
@@ -16,7 +16,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE
from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/mikrotik/device.py b/homeassistant/components/mikrotik/device.py
index bf3cb47adc3..7963c48d936 100644
--- a/homeassistant/components/mikrotik/device.py
+++ b/homeassistant/components/mikrotik/device.py
@@ -5,8 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, slugify
from .const import ATTR_DEVICE_TRACKER
diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py
index c2d9e0d2f33..19d5c789c09 100644
--- a/homeassistant/components/mikrotik/device_tracker.py
+++ b/homeassistant/components/mikrotik/device_tracker.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import MikrotikConfigEntry
from .coordinator import Device, MikrotikDataUpdateCoordinator
diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py
index 11199e126cf..116b3ef0341 100644
--- a/homeassistant/components/mill/__init__.py
+++ b/homeassistant/components/mill/__init__.py
@@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import MillDataUpdateCoordinator
-PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
+PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py
index 4f700d24e1b..0df2fe9335e 100644
--- a/homeassistant/components/mill/climate.py
+++ b/homeassistant/components/mill/climate.py
@@ -41,6 +41,7 @@ from .const import (
SERVICE_SET_ROOM_TEMP,
)
from .coordinator import MillDataUpdateCoordinator
+from .entity import MillBaseEntity
SET_ROOM_TEMP_SCHEMA = vol.Schema(
{
@@ -85,10 +86,9 @@ async def async_setup_entry(
)
-class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity):
+class MillHeater(MillBaseEntity, ClimateEntity):
"""Representation of a Mill Thermostat device."""
- _attr_has_entity_name = True
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_max_temp = MAX_TEMP
_attr_min_temp = MIN_TEMP
@@ -102,24 +102,13 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(
- self, coordinator: MillDataUpdateCoordinator, heater: mill.Heater
+ self, coordinator: MillDataUpdateCoordinator, device: mill.Heater
) -> None:
"""Initialize the thermostat."""
- super().__init__(coordinator)
-
- self._available = False
-
- self._id = heater.device_id
- self._attr_unique_id = heater.device_id
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, heater.device_id)},
- manufacturer=MANUFACTURER,
- model=heater.model,
- name=heater.name,
- )
-
- self._update_attr(heater)
+ super().__init__(coordinator, device)
+ self._attr_unique_id = device.device_id
+ self._update_attr(device)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -143,36 +132,25 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity):
)
await self.coordinator.async_request_refresh()
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return super().available and self._available
-
@callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
- self._update_attr(self.coordinator.data[self._id])
- self.async_write_ha_state()
-
- @callback
- def _update_attr(self, heater):
- self._available = heater.available
+ def _update_attr(self, device: mill.Heater) -> None:
+ self._available = device.available
self._attr_extra_state_attributes = {
- "open_window": heater.open_window,
- "controlled_by_tibber": heater.tibber_control,
+ "open_window": device.open_window,
+ "controlled_by_tibber": device.tibber_control,
}
- if heater.room_name:
- self._attr_extra_state_attributes["room"] = heater.room_name
- self._attr_extra_state_attributes["avg_room_temp"] = heater.room_avg_temp
+ if device.room_name:
+ self._attr_extra_state_attributes["room"] = device.room_name
+ self._attr_extra_state_attributes["avg_room_temp"] = device.room_avg_temp
else:
self._attr_extra_state_attributes["room"] = "Independent device"
- self._attr_target_temperature = heater.set_temp
- self._attr_current_temperature = heater.current_temp
- if heater.is_heating:
+ self._attr_target_temperature = device.set_temp
+ self._attr_current_temperature = device.current_temp
+ if device.is_heating:
self._attr_hvac_action = HVACAction.HEATING
else:
self._attr_hvac_action = HVACAction.IDLE
- if heater.power_status:
+ if device.power_status:
self._attr_hvac_mode = HVACMode.HEAT
else:
self._attr_hvac_mode = HVACMode.OFF
diff --git a/homeassistant/components/mill/entity.py b/homeassistant/components/mill/entity.py
new file mode 100644
index 00000000000..f24dbeb2c26
--- /dev/null
+++ b/homeassistant/components/mill/entity.py
@@ -0,0 +1,54 @@
+"""Base entity for Mill devices."""
+
+from __future__ import annotations
+
+from abc import abstractmethod
+
+from mill import Heater, MillDevice
+
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import MillDataUpdateCoordinator
+
+
+class MillBaseEntity(CoordinatorEntity[MillDataUpdateCoordinator]):
+ """Representation of a Mill number device."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: MillDataUpdateCoordinator,
+ mill_device: MillDevice,
+ ) -> None:
+ """Initialize the number."""
+ super().__init__(coordinator)
+
+ self._id = mill_device.device_id
+ self._available = False
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, mill_device.device_id)},
+ name=mill_device.name,
+ manufacturer=MANUFACTURER,
+ model=mill_device.model,
+ )
+ self._update_attr(mill_device)
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_attr(self.coordinator.data[self._id])
+ self.async_write_ha_state()
+
+ @abstractmethod
+ @callback
+ def _update_attr(self, device: MillDevice | Heater) -> None:
+ """Update the attribute of the entity."""
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self._available
diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json
index 6316eb72096..44c1136b7d5 100644
--- a/homeassistant/components/mill/manifest.json
+++ b/homeassistant/components/mill/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
- "requirements": ["millheater==0.12.2", "mill-local==0.3.0"]
+ "requirements": ["millheater==0.12.3", "mill-local==0.3.0"]
}
diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py
new file mode 100644
index 00000000000..af27159caf0
--- /dev/null
+++ b/homeassistant/components/mill/number.py
@@ -0,0 +1,61 @@
+"""Support for mill wifi-enabled home heaters."""
+
+from __future__ import annotations
+
+from mill import MillDevice
+
+from homeassistant.components.number import NumberDeviceClass, NumberEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_USERNAME, UnitOfPower
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import CLOUD, CONNECTION_TYPE, DOMAIN
+from .coordinator import MillDataUpdateCoordinator
+from .entity import MillBaseEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the Mill Number."""
+ if entry.data.get(CONNECTION_TYPE) == CLOUD:
+ mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][
+ entry.data[CONF_USERNAME]
+ ]
+
+ async_add_entities(
+ MillNumber(mill_data_coordinator, mill_device)
+ for mill_device in mill_data_coordinator.data.values()
+ )
+
+
+class MillNumber(MillBaseEntity, NumberEntity):
+ """Representation of a Mill number device."""
+
+ _attr_device_class = NumberDeviceClass.POWER
+ _attr_native_max_value = 2000
+ _attr_native_min_value = 0
+ _attr_native_step = 1
+ _attr_native_unit_of_measurement = UnitOfPower.WATT
+
+ def __init__(
+ self,
+ coordinator: MillDataUpdateCoordinator,
+ mill_device: MillDevice,
+ ) -> None:
+ """Initialize the number."""
+ super().__init__(coordinator, mill_device)
+ self._attr_unique_id = f"{mill_device.device_id}_max_heating_power"
+ self._update_attr(mill_device)
+
+ @callback
+ def _update_attr(self, device: MillDevice) -> None:
+ self._attr_native_value = device.data["deviceSettings"]["reported"].get(
+ "max_heater_power"
+ )
+ self._available = device.available and self._attr_native_value is not None
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set new value."""
+ await self.coordinator.mill_data_connection.max_heating_power(self._id, value)
diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py
index c4b975ab039..018b9466deb 100644
--- a/homeassistant/components/mill/sensor.py
+++ b/homeassistant/components/mill/sensor.py
@@ -25,6 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -41,6 +42,8 @@ from .const import (
TEMPERATURE,
TVOC,
)
+from .coordinator import MillDataUpdateCoordinator
+from .entity import MillBaseEntity
HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -179,37 +182,19 @@ async def async_setup_entry(
async_add_entities(entities)
-class MillSensor(CoordinatorEntity, SensorEntity):
+class MillSensor(MillBaseEntity, SensorEntity):
"""Representation of a Mill Sensor device."""
- _attr_has_entity_name = True
-
- def __init__(self, coordinator, entity_description, mill_device):
+ def __init__(
+ self,
+ coordinator: MillDataUpdateCoordinator,
+ entity_description: SensorEntityDescription,
+ mill_device: mill.Socket | mill.Heater,
+ ) -> None:
"""Initialize the sensor."""
- super().__init__(coordinator)
-
- self._id = mill_device.device_id
+ super().__init__(coordinator, mill_device)
self.entity_description = entity_description
- self._available = False
self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, mill_device.device_id)},
- name=mill_device.name,
- manufacturer=MANUFACTURER,
- model=mill_device.model,
- )
- self._update_attr(mill_device)
-
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
- self._update_attr(self.coordinator.data[self._id])
- self.async_write_ha_state()
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return super().available and self._available
@callback
def _update_attr(self, device):
@@ -217,12 +202,16 @@ class MillSensor(CoordinatorEntity, SensorEntity):
self._attr_native_value = getattr(device, self.entity_description.key)
-class LocalMillSensor(CoordinatorEntity, SensorEntity):
+class LocalMillSensor(CoordinatorEntity[MillDataUpdateCoordinator], SensorEntity):
"""Representation of a Mill Sensor device."""
_attr_has_entity_name = True
- def __init__(self, coordinator, entity_description):
+ def __init__(
+ self,
+ coordinator: MillDataUpdateCoordinator,
+ entity_description: SensorEntityDescription,
+ ) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
@@ -239,6 +228,6 @@ class LocalMillSensor(CoordinatorEntity, SensorEntity):
)
@property
- def native_value(self):
+ def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.coordinator.data[self.entity_description.key]
diff --git a/homeassistant/components/mill/strings.json b/homeassistant/components/mill/strings.json
index 21e3e7a44a5..212da68c3e9 100644
--- a/homeassistant/components/mill/strings.json
+++ b/homeassistant/components/mill/strings.json
@@ -63,15 +63,15 @@
},
"away_temp": {
"name": "Away temperature",
- "description": "Away temp."
+ "description": "Room temperature in away mode"
},
"comfort_temp": {
"name": "Comfort temperature",
- "description": "Comfort temp."
+ "description": "Room temperature in comfort mode"
},
"sleep_temp": {
"name": "Sleep temperature",
- "description": "Sleep temp."
+ "description": "Room temperature in sleep mode"
}
}
}
diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py
index f937c304471..f1392ea488a 100644
--- a/homeassistant/components/minecraft_server/__init__.py
+++ b/homeassistant/components/minecraft_server/__init__.py
@@ -20,8 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.device_registry as dr
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml
new file mode 100644
index 00000000000..fc3db3b3075
--- /dev/null
+++ b/homeassistant/components/minecraft_server/quality_scale.yaml
@@ -0,0 +1,114 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration doesn't provide any service actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow:
+ status: todo
+ comment: Check removal and replacement of name in config flow with the title (server address).
+ config-flow-test-coverage:
+ status: todo
+ comment: |
+ Merge test_show_config_form with full flow test.
+ Move full flow test to the top of all tests.
+ All test cases should end in either CREATE_ENTRY or ABORT.
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Integration doesn't provide any service actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: done
+ comment: Handled by coordinator.
+ entity-unique-id:
+ status: done
+ comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information.
+ has-entity-name: done
+ runtime-data: todo
+ test-before-configure: done
+ test-before-setup:
+ status: done
+ comment: |
+ Raising ConfigEntryNotReady, if either the initialization or
+ refresh of coordinator isn't successful.
+ unique-config-entry:
+ status: done
+ comment: |
+ As there is no unique information available from the dependency mcstatus,
+ the server address is used to identify that the same service is already configured.
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: Integration doesn't provide any service actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration doesn't support any configuration parameters.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: done
+ comment: Handled by coordinator.
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: Handled by coordinator.
+ parallel-updates:
+ status: todo
+ comment: |
+ Although this is handled by the coordinator and no service actions are provided,
+ PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule.
+ reauthentication-flow:
+ status: exempt
+ comment: No authentication is required for the integration.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery:
+ status: exempt
+ comment: No discovery possible.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No discovery possible. Users can use the (local or public) hostname instead of an IP address,
+ if static IP addresses cannot be configured.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: A minecraft server can only have one device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: No repair use-cases for this integration.
+ stale-devices: todo
+
+ # Platinum
+ async-dependency:
+ status: done
+ comment: |
+ Lookup API of the dependency mcstatus for Bedrock Edition servers is not async,
+ but is non-blocking and therefore OK to be called. Refer to mcstatus FAQ
+ https://mcstatus.readthedocs.io/en/stable/pages/faq/#why-doesn-t-bedrockserver-have-an-async-lookup-method
+ inject-websession:
+ status: exempt
+ comment: Integration isn't making any HTTP requests.
+ strict-typing: done
diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py
index 57a9632a6ff..18a82f3a8ed 100644
--- a/homeassistant/components/minio/__init__.py
+++ b/homeassistant/components/minio/__init__.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .minio_helper import MinioEventThread, create_minio_client
diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py
index c0efd302c47..1980c80ce69 100644
--- a/homeassistant/components/mobile_app/notify.py
+++ b/homeassistant/components/mobile_app/notify.py
@@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
ATTR_APP_DATA,
diff --git a/homeassistant/components/mochad/__init__.py b/homeassistant/components/mochad/__init__.py
index c8714c902a3..9e992b5babd 100644
--- a/homeassistant/components/mochad/__init__.py
+++ b/homeassistant/components/mochad/__init__.py
@@ -13,7 +13,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
index bbd2ba5c02d..5b1b78a5aef 100644
--- a/homeassistant/components/modbus/__init__.py
+++ b/homeassistant/components/modbus/__init__.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
-from typing import cast
import voluptuous as vol
@@ -49,7 +48,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
@@ -89,6 +88,8 @@ from .const import (
CONF_HVAC_MODE_OFF,
CONF_HVAC_MODE_REGISTER,
CONF_HVAC_MODE_VALUES,
+ CONF_HVAC_OFF_VALUE,
+ CONF_HVAC_ON_VALUE,
CONF_HVAC_ONOFF_REGISTER,
CONF_INPUT_TYPE,
CONF_MAX_TEMP,
@@ -130,6 +131,8 @@ from .const import (
CONF_WRITE_TYPE,
CONF_ZERO_SUPPRESS,
DEFAULT_HUB,
+ DEFAULT_HVAC_OFF_VALUE,
+ DEFAULT_HVAC_ON_VALUE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TEMP_UNIT,
MODBUS_DOMAIN as DOMAIN,
@@ -139,7 +142,7 @@ from .const import (
UDP,
DataType,
)
-from .modbus import ModbusHub, async_modbus_setup
+from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup
from .validators import (
duplicate_fan_mode_validator,
duplicate_swing_mode_validator,
@@ -256,6 +259,12 @@ CLIMATE_SCHEMA = vol.All(
vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float),
vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string,
vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int,
+ vol.Optional(
+ CONF_HVAC_ON_VALUE, default=DEFAULT_HVAC_ON_VALUE
+ ): cv.positive_int,
+ vol.Optional(
+ CONF_HVAC_OFF_VALUE, default=DEFAULT_HVAC_OFF_VALUE
+ ): cv.positive_int,
vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean,
vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe(
{
@@ -448,7 +457,7 @@ CONFIG_SCHEMA = vol.Schema(
def get_hub(hass: HomeAssistant, name: str) -> ModbusHub:
"""Return modbus hub with name."""
- return cast(ModbusHub, hass.data[DOMAIN][name])
+ return hass.data[DATA_MODBUS_HUBS][name]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -458,12 +467,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _reload_config(call: Event | ServiceCall) -> None:
"""Reload Modbus."""
- if DOMAIN not in hass.data:
+ if DATA_MODBUS_HUBS not in hass.data:
_LOGGER.error("Modbus cannot reload, because it was never loaded")
return
- hubs = hass.data[DOMAIN]
- for name in hubs:
- await hubs[name].async_close()
+ hubs = hass.data[DATA_MODBUS_HUBS]
+ for hub in hubs.values():
+ await hub.async_close()
reset_platforms = async_get_platforms(hass, DOMAIN)
for reset_platform in reset_platforms:
_LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain)
@@ -477,7 +486,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config)
- return await async_modbus_setup(
- hass,
- config,
- )
+ return await async_modbus_setup(hass, config)
diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py
index 97ade53762b..28d1be24587 100644
--- a/homeassistant/components/modbus/binary_sensor.py
+++ b/homeassistant/components/modbus/binary_sensor.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import datetime
import logging
from typing import Any
@@ -104,17 +103,13 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity):
if state := await self.async_get_last_state():
self._attr_is_on = state.state == STATE_ON
- async def async_update(self, now: datetime | None = None) -> None:
+ async def _async_update(self) -> None:
"""Update the state of the sensor."""
# do not allow multiple active calls to the same platform
- if self._call_active:
- return
- self._call_active = True
result = await self._hub.async_pb_call(
self._slave, self._address, self._count, self._input_type
)
- self._call_active = False
if result is None:
self._attr_available = False
self._result = []
@@ -126,7 +121,6 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity):
self._result = result.registers
self._attr_is_on = bool(self._result[0] & 1)
- self.async_write_ha_state()
if self._coordinator:
self._coordinator.async_set_updated_data(self._result)
@@ -159,7 +153,6 @@ class SlaveSensor(
"""Handle entity which will be added."""
if state := await self.async_get_last_state():
self._attr_is_on = state.state == STATE_ON
- self.async_write_ha_state()
await super().async_added_to_hass()
@callback
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
index 111c0458ef4..e1a2688048d 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import datetime
import logging
import struct
from typing import Any, cast
@@ -69,6 +68,8 @@ from .const import (
CONF_HVAC_MODE_OFF,
CONF_HVAC_MODE_REGISTER,
CONF_HVAC_MODE_VALUES,
+ CONF_HVAC_OFF_VALUE,
+ CONF_HVAC_ON_VALUE,
CONF_HVAC_ONOFF_REGISTER,
CONF_MAX_TEMP,
CONF_MIN_TEMP,
@@ -111,15 +112,10 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Read configuration and create Modbus climate."""
- if discovery_info is None:
+ if discovery_info is None or not (climates := discovery_info[CONF_CLIMATES]):
return
-
- entities = []
- for entity in discovery_info[CONF_CLIMATES]:
- hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME])
- entities.append(ModbusThermostat(hass, hub, entity))
-
- async_add_entities(entities)
+ hub = get_hub(hass, discovery_info[CONF_NAME])
+ async_add_entities(ModbusThermostat(hass, hub, config) for config in climates)
class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
@@ -251,6 +247,8 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
if CONF_HVAC_ONOFF_REGISTER in config:
self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER]
self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS]
+ self._hvac_on_value = config[CONF_HVAC_ON_VALUE]
+ self._hvac_off_value = config[CONF_HVAC_OFF_VALUE]
if HVACMode.OFF not in self._attr_hvac_modes:
self._attr_hvac_modes.append(HVACMode.OFF)
else:
@@ -266,19 +264,26 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if self._hvac_onoff_register is not None:
- # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise.
+ # Turn HVAC Off by writing self._hvac_off_value to the On/Off
+ # register, or self._hvac_on_value otherwise.
if self._hvac_onoff_write_registers:
await self._hub.async_pb_call(
self._slave,
self._hvac_onoff_register,
- [0 if hvac_mode == HVACMode.OFF else 1],
+ [
+ self._hvac_off_value
+ if hvac_mode == HVACMode.OFF
+ else self._hvac_on_value
+ ],
CALL_TYPE_WRITE_REGISTERS,
)
else:
await self._hub.async_pb_call(
self._slave,
self._hvac_onoff_register,
- 0 if hvac_mode == HVACMode.OFF else 1,
+ self._hvac_off_value
+ if hvac_mode == HVACMode.OFF
+ else self._hvac_on_value,
CALL_TYPE_WRITE_REGISTER,
)
@@ -302,7 +307,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
)
break
- await self.async_update()
+ await self._async_update_write_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
@@ -324,7 +329,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
CALL_TYPE_WRITE_REGISTER,
)
- await self.async_update()
+ await self._async_update_write_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing mode."""
@@ -347,7 +352,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
CALL_TYPE_WRITE_REGISTER,
)
break
- await self.async_update()
+ await self._async_update_write_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -402,9 +407,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
CALL_TYPE_WRITE_REGISTERS,
)
self._attr_available = result is not None
- await self.async_update()
+ await self._async_update_write_state()
- async def async_update(self, now: datetime | None = None) -> None:
+ async def _async_update(self) -> None:
"""Update Target & Current Temperature."""
# remark "now" is a dummy parameter to avoid problems with
# async_track_time_interval
@@ -476,11 +481,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
onoff = await self._async_read_register(
CALL_TYPE_REGISTER_HOLDING, self._hvac_onoff_register, raw=True
)
- if onoff == 0:
+ if onoff == self._hvac_off_value:
self._attr_hvac_mode = HVACMode.OFF
- self.async_write_ha_state()
-
async def _async_read_register(
self, register_type: str, register: int, raw: bool | None = False
) -> float | None:
diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py
index 7a1a4121a93..e11e15fff20 100644
--- a/homeassistant/components/modbus/const.py
+++ b/homeassistant/components/modbus/const.py
@@ -60,6 +60,8 @@ CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse"
CONF_FAN_MODE_VALUES = "values"
CONF_HVAC_MODE_REGISTER = "hvac_mode_register"
CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register"
+CONF_HVAC_ON_VALUE = "hvac_on_value"
+CONF_HVAC_OFF_VALUE = "hvac_off_value"
CONF_HVAC_MODE_OFF = "state_off"
CONF_HVAC_MODE_HEAT = "state_heat"
CONF_HVAC_MODE_COOL = "state_cool"
@@ -139,6 +141,8 @@ DEFAULT_SCAN_INTERVAL = 15 # seconds
DEFAULT_SLAVE = 1
DEFAULT_STRUCTURE_PREFIX = ">f"
DEFAULT_TEMP_UNIT = "C"
+DEFAULT_HVAC_ON_VALUE = 1
+DEFAULT_HVAC_OFF_VALUE = 0
MODBUS_DOMAIN = "modbus"
ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update
diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py
index eb9dac58900..5e7b008ff7c 100644
--- a/homeassistant/components/modbus/cover.py
+++ b/homeassistant/components/modbus/cover.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import datetime
from typing import Any
from homeassistant.components.cover import CoverEntity, CoverEntityFeature, CoverState
@@ -37,15 +36,10 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Read configuration and create Modbus cover."""
- if discovery_info is None:
+ if discovery_info is None or not (covers := discovery_info[CONF_COVERS]):
return
-
- covers = []
- for cover in discovery_info[CONF_COVERS]:
- hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME])
- covers.append(ModbusCover(hass, hub, cover))
-
- async_add_entities(covers)
+ hub = get_hub(hass, discovery_info[CONF_NAME])
+ async_add_entities(ModbusCover(hass, hub, config) for config in covers)
class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
@@ -117,7 +111,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
self._slave, self._write_address, self._state_open, self._write_type
)
self._attr_available = result is not None
- await self.async_update()
+ await self._async_update_write_state()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
@@ -125,9 +119,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
self._slave, self._write_address, self._state_closed, self._write_type
)
self._attr_available = result is not None
- await self.async_update()
+ await self._async_update_write_state()
- async def async_update(self, now: datetime | None = None) -> None:
+ async def _async_update(self) -> None:
"""Update the state of the cover."""
# remark "now" is a dummy parameter to avoid problems with
# async_track_time_interval
@@ -136,11 +130,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
)
if result is None:
self._attr_available = False
- self.async_write_ha_state()
return
self._attr_available = True
if self._input_type == CALL_TYPE_COIL:
self._set_attr_state(bool(result.bits[0] & 1))
else:
self._set_attr_state(int(result.registers[0]))
- self.async_write_ha_state()
diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py
index d252528f6d4..4684c2f2b8a 100644
--- a/homeassistant/components/modbus/entity.py
+++ b/homeassistant/components/modbus/entity.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
+import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
@@ -73,6 +74,11 @@ _LOGGER = logging.getLogger(__name__)
class BasePlatform(Entity):
"""Base for readonly platforms."""
+ _value: str | None = None
+ _attr_should_poll = False
+ _attr_available = True
+ _attr_unit_of_measurement = None
+
def __init__(
self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any]
) -> None:
@@ -85,62 +91,93 @@ class BasePlatform(Entity):
self._slave = entry.get(CONF_DEVICE_ADDRESS, 1)
self._address = int(entry[CONF_ADDRESS])
self._input_type = entry[CONF_INPUT_TYPE]
- self._value: str | None = None
self._scan_interval = int(entry[CONF_SCAN_INTERVAL])
- self._call_active = False
self._cancel_timer: Callable[[], None] | None = None
self._cancel_call: Callable[[], None] | None = None
self._attr_unique_id = entry.get(CONF_UNIQUE_ID)
self._attr_name = entry[CONF_NAME]
- self._attr_should_poll = False
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
- self._attr_available = True
- self._attr_unit_of_measurement = None
def get_optional_numeric_config(config_name: str) -> int | float | None:
if (val := entry.get(config_name)) is None:
return None
- assert isinstance(
- val, (float, int)
- ), f"Expected float or int but {config_name} was {type(val)}"
+ assert isinstance(val, (float, int)), (
+ f"Expected float or int but {config_name} was {type(val)}"
+ )
return val
self._min_value = get_optional_numeric_config(CONF_MIN_VALUE)
self._max_value = get_optional_numeric_config(CONF_MAX_VALUE)
self._nan_value = entry.get(CONF_NAN_VALUE)
self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS)
+ self._update_lock = asyncio.Lock()
@abstractmethod
- async def async_update(self, now: datetime | None = None) -> None:
+ async def _async_update(self) -> None:
"""Virtual function to be overwritten."""
+ async def async_update(self, now: datetime | None = None) -> None:
+ """Update the entity state."""
+ async with self._update_lock:
+ await self._async_update()
+
+ async def _async_update_write_state(self) -> None:
+ """Update the entity state and write it to the state machine."""
+ await self.async_update()
+ self.async_write_ha_state()
+
+ async def _async_update_if_not_in_progress(
+ self, now: datetime | None = None
+ ) -> None:
+ """Update the entity state if not already in progress."""
+ if self._update_lock.locked():
+ _LOGGER.debug("Update for entity %s is already in progress", self.name)
+ return
+ await self._async_update_write_state()
+
@callback
def async_run(self) -> None:
"""Remote start entity."""
- self.async_hold(update=False)
- self._cancel_call = async_call_later(
- self.hass, timedelta(milliseconds=100), self.async_update
- )
+ self._async_cancel_update_polling()
+ self._async_schedule_future_update(0.1)
if self._scan_interval > 0:
self._cancel_timer = async_track_time_interval(
- self.hass, self.async_update, timedelta(seconds=self._scan_interval)
+ self.hass,
+ self._async_update_if_not_in_progress,
+ timedelta(seconds=self._scan_interval),
)
self._attr_available = True
self.async_write_ha_state()
@callback
- def async_hold(self, update: bool = True) -> None:
- """Remote stop entity."""
+ def _async_schedule_future_update(self, delay: float) -> None:
+ """Schedule an update in the future."""
+ self._async_cancel_future_pending_update()
+ self._cancel_call = async_call_later(
+ self.hass, delay, self._async_update_if_not_in_progress
+ )
+
+ @callback
+ def _async_cancel_future_pending_update(self) -> None:
+ """Cancel a future pending update."""
if self._cancel_call:
self._cancel_call()
self._cancel_call = None
+
+ def _async_cancel_update_polling(self) -> None:
+ """Cancel the polling."""
if self._cancel_timer:
self._cancel_timer()
self._cancel_timer = None
- if update:
- self._attr_available = False
- self.async_write_ha_state()
+
+ @callback
+ def async_hold(self) -> None:
+ """Remote stop entity."""
+ self._async_cancel_future_pending_update()
+ self._async_cancel_update_polling()
+ self._attr_available = False
+ self.async_write_ha_state()
async def async_base_added_to_hass(self) -> None:
"""Handle entity which will be added."""
@@ -315,6 +352,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
self._attr_is_on = True
elif state.state == STATE_OFF:
self._attr_is_on = False
+ await super().async_added_to_hass()
async def async_turn(self, command: int) -> None:
"""Evaluate switch result."""
@@ -333,34 +371,29 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
return
if self._verify_delay:
- async_call_later(self.hass, self._verify_delay, self.async_update)
- else:
- await self.async_update()
+ self._async_schedule_future_update(self._verify_delay)
+ return
+
+ await self._async_update_write_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Set switch off."""
await self.async_turn(self._command_off)
- async def async_update(self, now: datetime | None = None) -> None:
+ async def _async_update(self) -> None:
"""Update the entity state."""
# remark "now" is a dummy parameter to avoid problems with
# async_track_time_interval
if not self._verify_active:
self._attr_available = True
- self.async_write_ha_state()
return
# do not allow multiple active calls to the same platform
- if self._call_active:
- return
- self._call_active = True
result = await self._hub.async_pb_call(
self._slave, self._verify_address, 1, self._verify_type
)
- self._call_active = False
if result is None:
self._attr_available = False
- self.async_write_ha_state()
return
self._attr_available = True
@@ -382,4 +415,3 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
self._verify_address,
value,
)
- self.async_write_ha_state()
diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py
index bed8ff102bb..8636ef4521a 100644
--- a/homeassistant/components/modbus/fan.py
+++ b/homeassistant/components/modbus/fan.py
@@ -25,14 +25,10 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Read configuration and create Modbus fans."""
- if discovery_info is None:
+ if discovery_info is None or not (fans := discovery_info[CONF_FANS]):
return
- fans = []
-
- for entry in discovery_info[CONF_FANS]:
- hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME])
- fans.append(ModbusFan(hass, hub, entry))
- async_add_entities(fans)
+ hub = get_hub(hass, discovery_info[CONF_NAME])
+ async_add_entities(ModbusFan(hass, hub, config) for config in fans)
class ModbusFan(BaseSwitch, FanEntity):
diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py
index 42745c2bb78..ce1c881733e 100644
--- a/homeassistant/components/modbus/light.py
+++ b/homeassistant/components/modbus/light.py
@@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import get_hub
from .entity import BaseSwitch
-from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -24,14 +23,10 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Read configuration and create Modbus lights."""
- if discovery_info is None:
+ if discovery_info is None or not (lights := discovery_info[CONF_LIGHTS]):
return
-
- lights = []
- for entry in discovery_info[CONF_LIGHTS]:
- hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME])
- lights.append(ModbusLight(hass, hub, entry))
- async_add_entities(lights)
+ hub = get_hub(hass, discovery_info[CONF_NAME])
+ async_add_entities(ModbusLight(hass, hub, config) for config in lights)
class ModbusLight(BaseSwitch, LightEntity):
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
index fc25a329c11..120175c65c2 100644
--- a/homeassistant/components/modbus/manifest.json
+++ b/homeassistant/components/modbus/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
- "requirements": ["pymodbus==3.7.4"]
+ "requirements": ["pymodbus==3.8.3"]
}
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index fce831e9cd4..81cfc3127d1 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -30,11 +30,12 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_ADDRESS,
@@ -70,50 +71,59 @@ from .const import (
from .validators import check_config
_LOGGER = logging.getLogger(__name__)
+DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN)
-ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") # noqa: PYI024
-RunEntry = namedtuple("RunEntry", "attr func") # noqa: PYI024
+ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024
+RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024
PB_CALL = [
ConfEntry(
CALL_TYPE_COIL,
"bits",
"read_coils",
+ "count",
),
ConfEntry(
CALL_TYPE_DISCRETE,
"bits",
"read_discrete_inputs",
+ "count",
),
ConfEntry(
CALL_TYPE_REGISTER_HOLDING,
"registers",
"read_holding_registers",
+ "count",
),
ConfEntry(
CALL_TYPE_REGISTER_INPUT,
"registers",
"read_input_registers",
+ "count",
),
ConfEntry(
CALL_TYPE_WRITE_COIL,
- "value",
+ "bits",
"write_coil",
+ "value",
),
ConfEntry(
CALL_TYPE_WRITE_COILS,
"count",
"write_coils",
+ "values",
),
ConfEntry(
CALL_TYPE_WRITE_REGISTER,
- "value",
+ "registers",
"write_register",
+ "value",
),
ConfEntry(
CALL_TYPE_WRITE_REGISTERS,
"count",
"write_registers",
+ "values",
),
]
@@ -128,14 +138,14 @@ async def async_modbus_setup(
config[DOMAIN] = check_config(hass, config[DOMAIN])
if not config[DOMAIN]:
return False
- if DOMAIN in hass.data and config[DOMAIN] == []:
- hubs = hass.data[DOMAIN]
- for name in hubs:
- if not await hubs[name].async_setup():
+ if DATA_MODBUS_HUBS in hass.data and config[DOMAIN] == []:
+ hubs = hass.data[DATA_MODBUS_HUBS]
+ for hub in hubs.values():
+ if not await hub.async_setup():
return False
- hub_collect = hass.data[DOMAIN]
+ hub_collect = hass.data[DATA_MODBUS_HUBS]
else:
- hass.data[DOMAIN] = hub_collect = {}
+ hass.data[DATA_MODBUS_HUBS] = hub_collect = {}
for conf_hub in config[DOMAIN]:
my_hub = ModbusHub(hass, conf_hub)
@@ -322,7 +332,9 @@ class ModbusHub:
for entry in PB_CALL:
func = getattr(self._client, entry.func_name)
- self._pb_request[entry.call_type] = RunEntry(entry.attr, func)
+ self._pb_request[entry.call_type] = RunEntry(
+ entry.attr, func, entry.value_attr_name
+ )
self.hass.async_create_background_task(
self.async_pb_connect(), "modbus-connect"
@@ -372,8 +384,9 @@ class ModbusHub:
{ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1}
)
entry = self._pb_request[use_call]
+ kwargs[entry.value_attr_name] = value
try:
- result: ModbusPDU = await entry.func(address, value, **kwargs)
+ result: ModbusPDU = await entry.func(address, **kwargs)
except ModbusException as exception_error:
error = f"Error: device: {slave} address: {address} -> {exception_error!s}"
self._log_error(error)
diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py
index d5a16c95cc4..2c2efb70d5a 100644
--- a/homeassistant/components/modbus/sensor.py
+++ b/homeassistant/components/modbus/sensor.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import datetime
import logging
from typing import Any
@@ -106,7 +105,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity):
if state:
self._attr_native_value = state.native_value
- async def async_update(self, now: datetime | None = None) -> None:
+ async def _async_update(self) -> None:
"""Update the state of the sensor."""
# remark "now" is a dummy parameter to avoid problems with
# async_track_time_interval
diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py
index 71413391a5f..44b0575d419 100644
--- a/homeassistant/components/modbus/switch.py
+++ b/homeassistant/components/modbus/switch.py
@@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import get_hub
from .entity import BaseSwitch
-from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -24,15 +23,10 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Read configuration and create Modbus switches."""
- switches = []
-
- if discovery_info is None:
+ if discovery_info is None or not (switches := discovery_info[CONF_SWITCHES]):
return
-
- for entry in discovery_info[CONF_SWITCHES]:
- hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME])
- switches.append(ModbusSwitch(hass, hub, entry))
- async_add_entities(switches)
+ hub = get_hub(hass, discovery_info[CONF_NAME])
+ async_add_entities(ModbusSwitch(hass, hub, config) for config in switches)
class ModbusSwitch(BaseSwitch, SwitchEntity):
diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py
index 98e6708a34c..237fafa69d7 100644
--- a/homeassistant/components/modem_callerid/config_flow.py
+++ b/homeassistant/components/modem_callerid/config_flow.py
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_NAME
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS
@@ -30,9 +31,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
"""Set up flow instance."""
self._device: str | None = None
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB Discovery."""
dev_path = discovery_info.device
unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}"
diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py
index 3c217b5747f..d10c7604722 100644
--- a/homeassistant/components/modern_forms/config_flow.py
+++ b/homeassistant/components/modern_forms/config_flow.py
@@ -7,10 +7,10 @@ from typing import Any
from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -39,7 +39,7 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._handle_config_flow()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
host = discovery_info.hostname.rstrip(".")
diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py
index 262d13ad3af..750ddce8513 100644
--- a/homeassistant/components/mold_indicator/sensor.py
+++ b/homeassistant/components/mold_indicator/sensor.py
@@ -34,7 +34,7 @@ from homeassistant.core import (
State,
callback,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py
index 5bfdc02c61e..e6ab84a4e74 100644
--- a/homeassistant/components/monarch_money/config_flow.py
+++ b/homeassistant/components/monarch_money/config_flow.py
@@ -87,7 +87,7 @@ async def validate_login(
except LoginFailedException as err:
raise InvalidAuth from err
- LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}")
+ LOGGER.debug("Connection successful - saving session to file %s", SESSION_FILE)
LOGGER.debug("Obtaining subscription id")
subs: MonarchSubscription = await monarch_client.get_subscription_details()
assert subs is not None
diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json
index ed28f825bcf..d45415bbcd7 100644
--- a/homeassistant/components/monarch_money/manifest.json
+++ b/homeassistant/components/monarch_money/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/monarchmoney",
"iot_class": "cloud_polling",
- "requirements": ["typedmonarchmoney==0.3.1"]
+ "requirements": ["typedmonarchmoney==0.4.4"]
}
diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py
index 1e2674a24bf..09048579859 100644
--- a/homeassistant/components/moon/sensor.py
+++ b/homeassistant/components/moon/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py
index 2e35ff4283f..e5b7d5d7dd2 100644
--- a/homeassistant/components/mopeka/config_flow.py
+++ b/homeassistant/components/mopeka/config_flow.py
@@ -111,7 +111,7 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN):
data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]},
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py
index e961880375c..d8d1e7c21f1 100644
--- a/homeassistant/components/motion_blinds/config_flow.py
+++ b/homeassistant/components/motion_blinds/config_flow.py
@@ -7,7 +7,6 @@ from typing import Any
from motionblinds import MotionDiscovery, MotionGateway
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -17,6 +16,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_INTERFACE,
@@ -82,7 +82,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
return OptionsFlowHandler()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
mac_address = format_mac(discovery_info.macaddress).replace(":", "")
diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py
index 28963d83d89..9b27ce9bc6c 100644
--- a/homeassistant/components/motionmount/__init__.py
+++ b/homeassistant/components/motionmount/__init__.py
@@ -7,9 +7,9 @@ import socket
import motionmount
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_PORT, Platform
+from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, EMPTY_MAC
@@ -48,6 +48,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}"
)
+ # Check we're properly authenticated or be able to become so
+ if not mm.is_authenticated:
+ if CONF_PIN not in entry.data:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="no_pin_provided",
+ )
+
+ pin = entry.data[CONF_PIN]
+ await mm.authenticate(pin)
+ if not mm.is_authenticated:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="incorrect_pin",
+ )
+
# Store an API object for your platforms to access
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm
diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py
index 19d3557d36b..283f1f01d6e 100644
--- a/homeassistant/components/motionmount/config_flow.py
+++ b/homeassistant/components/motionmount/config_flow.py
@@ -1,5 +1,7 @@
"""Config flow for Vogel's MotionMount."""
+import asyncio
+from collections.abc import Mapping
import logging
import socket
from typing import Any
@@ -7,14 +9,15 @@ from typing import Any
import motionmount
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
DEFAULT_DISCOVERY_UNIQUE_ID,
+ SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
)
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT, CONF_UUID
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, EMPTY_MAC
@@ -34,7 +37,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up the instance."""
- self.discovery_info: dict[str, Any] = {}
+ self.connection_data: dict[str, Any] = {}
+ self.backoff_task: asyncio.Task | None = None
+ self.backoff_time: int = 0
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -43,23 +48,16 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self._show_setup_form()
+ self.connection_data.update(user_input)
info = {}
try:
- info = await self._validate_input(user_input)
+ info = await self._validate_input_connect(self.connection_data)
except (ConnectionError, socket.gaierror):
return self.async_abort(reason="cannot_connect")
except TimeoutError:
return self.async_abort(reason="time_out")
except motionmount.NotConnectedError:
return self.async_abort(reason="not_connected")
- except motionmount.MotionMountResponseError:
- # This is most likely due to missing support for the mac address property
- # Abort if the handler has config entries already
- if self._async_current_entries():
- return self.async_abort(reason="already_configured")
-
- # Otherwise we try to continue with the generic uid
- info[CONF_UUID] = DEFAULT_DISCOVERY_UNIQUE_ID
# If the device mac is valid we use it, otherwise we use the default id
if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC:
@@ -67,20 +65,25 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
else:
unique_id = DEFAULT_DISCOVERY_UNIQUE_ID
- name = info.get(CONF_NAME, user_input[CONF_HOST])
+ name = info.get(CONF_NAME, self.connection_data[CONF_HOST])
+ self.connection_data[CONF_NAME] = name
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={
- CONF_HOST: user_input[CONF_HOST],
- CONF_PORT: user_input[CONF_PORT],
+ CONF_HOST: self.connection_data[CONF_HOST],
+ CONF_PORT: self.connection_data[CONF_PORT],
}
)
- return self.async_create_entry(title=name, data=user_input)
+ if not info[CONF_PIN]:
+ # We need a pin to authenticate
+ return await self.async_step_auth()
+ # No pin is needed
+ return self._create_or_update_entry()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
@@ -91,7 +94,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
name = discovery_info.name.removesuffix(f".{zctype}")
unique_id = discovery_info.properties.get("mac")
- self.discovery_info.update(
+ self.connection_data.update(
{
CONF_HOST: host,
CONF_PORT: port,
@@ -114,16 +117,13 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
self.context.update({"title_placeholders": {"name": name}})
try:
- info = await self._validate_input(self.discovery_info)
+ info = await self._validate_input_connect(self.connection_data)
except (ConnectionError, socket.gaierror):
return self.async_abort(reason="cannot_connect")
except TimeoutError:
return self.async_abort(reason="time_out")
except motionmount.NotConnectedError:
return self.async_abort(reason="not_connected")
- except motionmount.MotionMountResponseError:
- info = {}
- # We continue as we want to be able to connect with older FW that does not support MAC address
# If the device supplied as with a valid MAC we use that
if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC:
@@ -137,6 +137,10 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
else:
await self._async_handle_discovery_without_unique_id()
+ if not info[CONF_PIN]:
+ # We need a pin to authenticate
+ return await self.async_step_auth()
+ # No pin is needed
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
@@ -146,16 +150,82 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="zeroconf_confirm",
- description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]},
+ description_placeholders={CONF_NAME: self.connection_data[CONF_NAME]},
errors={},
)
- return self.async_create_entry(
- title=self.discovery_info[CONF_NAME],
- data=self.discovery_info,
+ return self._create_or_update_entry()
+
+ async def async_step_reauth(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication."""
+ reauth_entry = self._get_reauth_entry()
+ self.connection_data.update(reauth_entry.data)
+ return await self.async_step_auth()
+
+ async def async_step_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle authentication form."""
+ errors = {}
+
+ if user_input is not None:
+ self.connection_data[CONF_PIN] = user_input[CONF_PIN]
+
+ # Validate pin code
+ valid_or_wait_time = await self._validate_input_pin(self.connection_data)
+ if valid_or_wait_time is True:
+ return self._create_or_update_entry()
+
+ if type(valid_or_wait_time) is int:
+ self.backoff_time = valid_or_wait_time
+ self.backoff_task = self.hass.async_create_task(
+ self._backoff(valid_or_wait_time)
+ )
+ return await self.async_step_backoff()
+
+ errors[CONF_PIN] = CONF_PIN
+
+ return self.async_show_form(
+ step_id="auth",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_PIN): vol.All(int, vol.Range(min=1, max=9999)),
+ }
+ ),
+ errors=errors,
)
- async def _validate_input(self, data: dict) -> dict[str, Any]:
+ async def async_step_backoff(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle backoff progress."""
+ if not self.backoff_task or self.backoff_task.done():
+ self.backoff_task = None
+ return self.async_show_progress_done(next_step_id="auth")
+
+ return self.async_show_progress(
+ step_id="backoff",
+ description_placeholders={
+ "timeout": str(self.backoff_time),
+ },
+ progress_action="progress_action",
+ progress_task=self.backoff_task,
+ )
+
+ def _create_or_update_entry(self) -> ConfigFlowResult:
+ if self.source == SOURCE_REAUTH:
+ reauth_entry = self._get_reauth_entry()
+ return self.async_update_reload_and_abort(
+ reauth_entry, data_updates=self.connection_data
+ )
+ return self.async_create_entry(
+ title=self.connection_data[CONF_NAME],
+ data=self.connection_data,
+ )
+
+ async def _validate_input_connect(self, data: dict) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT])
@@ -164,7 +234,33 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
finally:
await mm.disconnect()
- return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name}
+ return {
+ CONF_UUID: format_mac(mm.mac.hex()),
+ CONF_NAME: mm.name,
+ CONF_PIN: mm.is_authenticated,
+ }
+
+ async def _validate_input_pin(self, data: dict) -> bool | int:
+ """Validate the user input allows us to authenticate."""
+
+ mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT])
+ try:
+ await mm.connect()
+
+ can_authenticate = mm.can_authenticate
+ if can_authenticate is True:
+ await mm.authenticate(data[CONF_PIN])
+ else:
+ # The backoff is running, return the remaining time
+ return can_authenticate
+ finally:
+ await mm.disconnect()
+
+ can_authenticate = mm.can_authenticate
+ if can_authenticate is True:
+ return mm.is_authenticated
+
+ return can_authenticate
def _show_setup_form(
self, errors: dict[str, str] | None = None
@@ -180,3 +276,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
),
errors=errors or {},
)
+
+ async def _backoff(self, time: int) -> None:
+ while time > 0:
+ time -= 1
+ self.backoff_time = time
+ await asyncio.sleep(1)
diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py
index ba81c9d10bd..0a774e6efb6 100644
--- a/homeassistant/components/motionmount/entity.py
+++ b/homeassistant/components/motionmount/entity.py
@@ -1,13 +1,12 @@
"""Support for MotionMount sensors."""
import logging
-import socket
from typing import TYPE_CHECKING
import motionmount
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS
+from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
@@ -26,6 +25,11 @@ class MotionMountEntity(Entity):
def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None:
"""Initialize general MotionMount entity."""
self.mm = mm
+ self.config_entry = config_entry
+
+ # We store the pin, as we might need it during reconnect
+ self.pin = config_entry.data.get(CONF_PIN)
+
mac = format_mac(mm.mac.hex())
# Create a base unique id
@@ -74,23 +78,3 @@ class MotionMountEntity(Entity):
self.mm.remove_listener(self.async_write_ha_state)
self.mm.remove_listener(self.update_name)
await super().async_will_remove_from_hass()
-
- async def _ensure_connected(self) -> bool:
- """Make sure there is a connection with the MotionMount.
-
- Returns false if the connection failed to be ensured.
- """
-
- if self.mm.is_connected:
- return True
- try:
- await self.mm.connect()
- except (ConnectionError, TimeoutError, socket.gaierror):
- # We're not interested in exceptions here. In case of a failed connection
- # the try/except from the caller will report it.
- # The purpose of `_ensure_connected()` is only to make sure we try to
- # reconnect, where failures should not be logged each time
- return False
- else:
- _LOGGER.warning("Successfully reconnected to MotionMount")
- return True
diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py
index 9b43d901a21..23fcf576af0 100644
--- a/homeassistant/components/motionmount/select.py
+++ b/homeassistant/components/motionmount/select.py
@@ -51,6 +51,38 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
self._attr_options = options
+ async def _ensure_connected(self) -> bool:
+ """Make sure there is a connection with the MotionMount.
+
+ Returns false if the connection failed to be ensured.
+ """
+ if self.mm.is_connected:
+ return True
+ try:
+ await self.mm.connect()
+ except (ConnectionError, TimeoutError, socket.gaierror):
+ # We're not interested in exceptions here. In case of a failed connection
+ # the try/except from the caller will report it.
+ # The purpose of `_ensure_connected()` is only to make sure we try to
+ # reconnect, where failures should not be logged each time
+ return False
+
+ # Check we're properly authenticated or be able to become so
+ if not self.mm.is_authenticated:
+ if self.pin is None:
+ await self.mm.disconnect()
+ self.config_entry.async_start_reauth(self.hass)
+ return False
+ await self.mm.authenticate(self.pin)
+ if not self.mm.is_authenticated:
+ self.pin = None
+ await self.mm.disconnect()
+ self.config_entry.async_start_reauth(self.hass)
+ return False
+
+ _LOGGER.debug("Successfully reconnected to MotionMount")
+ return True
+
async def async_update(self) -> None:
"""Get latest state from MotionMount."""
if not await self._ensure_connected():
diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json
index bd28156607c..bef04634431 100644
--- a/homeassistant/components/motionmount/strings.json
+++ b/homeassistant/components/motionmount/strings.json
@@ -1,4 +1,7 @@
{
+ "common": {
+ "incorrect_pin": "PIN is not correct"
+ },
"config": {
"flow_title": "{name}",
"step": {
@@ -13,15 +16,33 @@
"zeroconf_confirm": {
"description": "Do you want to set up {name}?",
"title": "Discovered MotionMount"
+ },
+ "auth": {
+ "title": "Authenticate to your MotionMount",
+ "description": "Your MotionMount requires a PIN to operate.",
+ "data": {
+ "pin": "[%key:common::config_flow::data::pin%]"
+ }
+ },
+ "backoff": {
+ "title": "Authenticate to your MotionMount",
+ "description": "Too many incorrect PIN attempts."
}
},
+ "error": {
+ "pin": "[%key:component::motionmount::common::incorrect_pin%]"
+ },
+ "progress": {
+ "progress_action": "Too many incorrect PIN attempts. Please wait {timeout} s..."
+ },
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "time_out": "Failed to connect due to a time out.",
+ "time_out": "[%key:common::config_flow::error::timeout_connect%]",
"not_connected": "Failed to connect.",
- "invalid_response": "Failed to connect due to an invalid response from the MotionMount."
+ "invalid_response": "Failed to connect due to an invalid response from the MotionMount.",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
@@ -60,6 +81,12 @@
"exceptions": {
"failed_communication": {
"message": "Failed to communicate with MotionMount"
+ },
+ "no_pin_provided": {
+ "message": "No PIN provided"
+ },
+ "incorrect_pin": {
+ "message": "[%key:component::motionmount::common::incorrect_pin%]"
}
}
}
diff --git a/homeassistant/components/mpd/config_flow.py b/homeassistant/components/mpd/config_flow.py
index 36777a205f9..e4bfc2136d8 100644
--- a/homeassistant/components/mpd/config_flow.py
+++ b/homeassistant/components/mpd/config_flow.py
@@ -10,7 +10,7 @@ from mpd.asyncio import MPDClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from .const import DOMAIN, LOGGER
@@ -66,36 +66,3 @@ class MPDConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=SCHEMA,
errors=errors,
)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Attempt to import the existing configuration."""
- self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
- client = MPDClient()
- client.timeout = 30
- client.idletimeout = 10
- try:
- async with timeout(35):
- await client.connect(import_data[CONF_HOST], import_data[CONF_PORT])
- if CONF_PASSWORD in import_data:
- await client.password(import_data[CONF_PASSWORD])
- with suppress(mpd.ConnectionError):
- client.disconnect()
- except (
- TimeoutError,
- gaierror,
- mpd.ConnectionError,
- OSError,
- ):
- return self.async_abort(reason="cannot_connect")
- except Exception: # noqa: BLE001
- LOGGER.exception("Unknown exception")
- return self.async_abort(reason="unknown")
-
- return self.async_create_entry(
- title=import_data.get(CONF_NAME, "Music Player Daemon"),
- data={
- CONF_HOST: import_data[CONF_HOST],
- CONF_PORT: import_data[CONF_PORT],
- CONF_PASSWORD: import_data.get(CONF_PASSWORD),
- },
- )
diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py
index 92f0f5cfcc4..db3901016f7 100644
--- a/homeassistant/components/mpd/media_player.py
+++ b/homeassistant/components/mpd/media_player.py
@@ -26,17 +26,13 @@ from homeassistant.components.media_player import (
RepeatMode,
async_process_play_media_url,
)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
-import homeassistant.helpers.config_validation as cv
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
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.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, LOGGER
@@ -71,54 +67,6 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the MPD platform."""
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
- if (
- result["type"] is FlowResultType.CREATE_ENTRY
- or result["reason"] == "already_configured"
- ):
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Music Player Daemon",
- },
- )
- return
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{result['reason']}",
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Music Player Daemon",
- },
- )
-
-
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
diff --git a/homeassistant/components/mpd/strings.json b/homeassistant/components/mpd/strings.json
index fc922ab128a..44cbe8b2bb2 100644
--- a/homeassistant/components/mpd/strings.json
+++ b/homeassistant/components/mpd/strings.json
@@ -19,15 +19,5 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
- },
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The {integration_title} YAML configuration import cannot connect to daemon",
- "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually."
- },
- "deprecated_yaml_import_issue_unknown": {
- "title": "The {integration_title} YAML configuration could not be imported",
- "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually."
- }
}
}
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index bcad8747c39..8b16e9fa53d 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -6,14 +6,14 @@ import asyncio
from collections.abc import Callable
from datetime import datetime
import logging
-from typing import TYPE_CHECKING, Any, cast
+from typing import Any, cast
import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, SERVICE_RELOAD
+from homeassistant.const import CONF_DISCOVERY, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigValidationError,
@@ -25,7 +25,6 @@ from homeassistant.helpers import (
entity_registry as er,
event as ev,
issue_registry as ir,
- template,
)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -39,7 +38,7 @@ from homeassistant.util.async_ import create_eager_task
# Loading the config flow file will register the flow
from . import debug_info, discovery
-from .client import ( # noqa: F401
+from .client import (
MQTT,
async_publish,
async_subscribe,
@@ -47,9 +46,9 @@ from .client import ( # noqa: F401
publish,
subscribe,
)
-from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401
+from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
from .config_integration import CONFIG_SCHEMA_BASE
-from .const import ( # noqa: F401
+from .const import (
ATTR_PAYLOAD,
ATTR_QOS,
ATTR_RETAIN,
@@ -70,6 +69,8 @@ from .const import ( # noqa: F401
CONF_WILL_MESSAGE,
CONF_WS_HEADERS,
CONF_WS_PATH,
+ CONFIG_ENTRY_MINOR_VERSION,
+ CONFIG_ENTRY_VERSION,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PREFIX,
@@ -77,10 +78,11 @@ from .const import ( # noqa: F401
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
+ ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
TEMPLATE_ERRORS,
)
-from .models import ( # noqa: F401
+from .models import (
DATA_MQTT,
DATA_MQTT_AVAILABLE,
MqttCommandTemplate,
@@ -91,13 +93,13 @@ from .models import ( # noqa: F401
ReceiveMessage,
convert_outgoing_mqtt_payload,
)
-from .subscription import ( # noqa: F401
+from .subscription import (
EntitySubscription,
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
-from .util import ( # noqa: F401
+from .util import (
async_create_certificate_temp_files,
async_forward_entry_setup_and_setup_discovery,
async_wait_for_mqtt_client,
@@ -108,13 +110,88 @@ from .util import ( # noqa: F401
valid_subscribe_topic,
)
+__all__ = [
+ "ATTR_PAYLOAD",
+ "ATTR_QOS",
+ "ATTR_RETAIN",
+ "ATTR_TOPIC",
+ "CONFIG_ENTRY_MINOR_VERSION",
+ "CONFIG_ENTRY_VERSION",
+ "CONF_BIRTH_MESSAGE",
+ "CONF_BROKER",
+ "CONF_CERTIFICATE",
+ "CONF_CLIENT_CERT",
+ "CONF_CLIENT_KEY",
+ "CONF_COMMAND_TOPIC",
+ "CONF_DISCOVERY_PREFIX",
+ "CONF_KEEPALIVE",
+ "CONF_QOS",
+ "CONF_STATE_TOPIC",
+ "CONF_TLS_INSECURE",
+ "CONF_TOPIC",
+ "CONF_TRANSPORT",
+ "CONF_WILL_MESSAGE",
+ "CONF_WS_HEADERS",
+ "CONF_WS_PATH",
+ "DATA_MQTT",
+ "DATA_MQTT_AVAILABLE",
+ "DEFAULT_DISCOVERY",
+ "DEFAULT_ENCODING",
+ "DEFAULT_PREFIX",
+ "DEFAULT_QOS",
+ "DEFAULT_RETAIN",
+ "DOMAIN",
+ "ENTITY_PLATFORMS",
+ "ENTRY_OPTION_FIELDS",
+ "MQTT",
+ "MQTT_BASE_SCHEMA",
+ "MQTT_CONNECTION_STATE",
+ "MQTT_RO_SCHEMA",
+ "MQTT_RW_SCHEMA",
+ "SERVICE_RELOAD",
+ "TEMPLATE_ERRORS",
+ "EntitySubscription",
+ "MqttCommandTemplate",
+ "MqttData",
+ "MqttValueTemplate",
+ "PayloadSentinel",
+ "PublishPayloadType",
+ "ReceiveMessage",
+ "SetupPhases",
+ "async_check_config_schema",
+ "async_create_certificate_temp_files",
+ "async_forward_entry_setup_and_setup_discovery",
+ "async_migrate_entry",
+ "async_prepare_subscribe_topics",
+ "async_publish",
+ "async_remove_config_entry_device",
+ "async_setup",
+ "async_setup_entry",
+ "async_subscribe",
+ "async_subscribe_connection_status",
+ "async_subscribe_topics",
+ "async_unload_entry",
+ "async_unsubscribe_topics",
+ "async_wait_for_mqtt_client",
+ "convert_outgoing_mqtt_payload",
+ "create_eager_task",
+ "is_connected",
+ "mqtt_config_entry_enabled",
+ "platforms_from_config",
+ "publish",
+ "subscribe",
+ "valid_publish_topic",
+ "valid_qos_schema",
+ "valid_subscribe_topic",
+ "websocket_mqtt_info",
+ "websocket_subscribe",
+]
+
_LOGGER = logging.getLogger(__name__)
SERVICE_PUBLISH = "publish"
SERVICE_DUMP = "dump"
-ATTR_TOPIC_TEMPLATE = "topic_template"
-ATTR_PAYLOAD_TEMPLATE = "payload_template"
ATTR_EVALUATE_PAYLOAD = "evaluate_payload"
MAX_RECONNECT_WAIT = 300 # seconds
@@ -155,25 +232,16 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-
-# The use of a topic_template and payload_template in an mqtt publish action call
-# have been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0
-
# Publish action call validation schema
-MQTT_PUBLISH_SCHEMA = vol.All(
- vol.Schema(
- {
- vol.Exclusive(ATTR_TOPIC, CONF_TOPIC): valid_publish_topic,
- 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,
- },
- required=True,
- ),
- cv.has_at_least_one_key(ATTR_TOPIC, ATTR_TOPIC_TEMPLATE),
+MQTT_PUBLISH_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_TOPIC): valid_publish_topic,
+ vol.Required(ATTR_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,
+ },
+ required=True,
)
@@ -233,86 +301,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_publish_service(call: ServiceCall) -> None:
"""Handle MQTT publish service calls."""
- msg_topic: str | None = call.data.get(ATTR_TOPIC)
- msg_topic_template: str | None = call.data.get(ATTR_TOPIC_TEMPLATE)
+ msg_topic: str = call.data[ATTR_TOPIC]
if not mqtt_config_entry_enabled(hass):
raise ServiceValidationError(
translation_key="mqtt_not_setup_cannot_publish",
translation_domain=DOMAIN,
- translation_placeholders={
- "topic": str(msg_topic or msg_topic_template)
- },
+ translation_placeholders={"topic": msg_topic},
)
mqtt_data = hass.data[DATA_MQTT]
- payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD)
+ payload: PublishPayloadType = call.data[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]
- if msg_topic_template is not None:
- # The use of a topic_template in an mqtt publish action call
- # 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),
- ).async_render()
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"topic_template_deprecation_{rendered_topic}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="topic_template_deprecation",
- translation_placeholders={
- "topic_template": msg_topic_template,
- "topic": rendered_topic,
- },
- )
- try:
- msg_topic = valid_publish_topic(rendered_topic)
- except vol.Invalid as err:
- err_str = str(err)
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_publish_topic",
- translation_placeholders={
- "error": err_str,
- "topic": str(rendered_topic),
- "topic_template": str(msg_topic_template),
- },
- ) from err
- if payload_template is not None:
- # The use of a payload_template in an mqtt publish action call
- # has been deprecated with HA Core 2024.8.0
- # and will be removed with HA Core 2025.2.0
- if TYPE_CHECKING:
- assert msg_topic is not None
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"payload_template_deprecation_{msg_topic}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="payload_template_deprecation",
- translation_placeholders={
- "topic": msg_topic,
- "payload_template": payload_template,
- },
- )
- payload = MqttCommandTemplate(
- template.Template(payload_template, hass)
- ).async_render()
- elif evaluate_payload:
+ if evaluate_payload:
# Convert quoted binary literal to raw data
payload = convert_outgoing_mqtt_payload(payload)
- if TYPE_CHECKING:
- assert msg_topic is not None
await mqtt_data.client.async_publish(msg_topic, payload, qos, retain)
hass.services.async_register(
@@ -355,15 +362,45 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
+async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Migrate the options from config entry data."""
+ _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
+ data: dict[str, Any] = dict(entry.data)
+ options: dict[str, Any] = dict(entry.options)
+ if entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if entry.version == 1 and entry.minor_version < 2:
+ # Can be removed when config entry is bumped to version 2.1
+ # with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
+ # From 2026.1 we will write version 2.1
+ for key in ENTRY_OPTION_FIELDS:
+ if key not in data:
+ continue
+ options[key] = data.pop(key)
+ hass.config_entries.async_update_entry(
+ entry,
+ data=data,
+ options=options,
+ version=CONFIG_ENTRY_VERSION,
+ minor_version=CONFIG_ENTRY_MINOR_VERSION,
+ )
+
+ _LOGGER.debug(
+ "Migration to version %s:%s successful", entry.version, entry.minor_version
+ )
+ return True
+
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
- conf: dict[str, Any]
mqtt_data: MqttData
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
# Fetch configuration
- conf = dict(entry.data)
+ conf = dict(entry.data | entry.options)
hass_config = await conf_util.async_hass_config_yaml(hass)
mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, [])
await async_create_certificate_temp_files(hass, conf)
diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py
index 65e24d5d780..584b238b3a8 100644
--- a/homeassistant/components/mqtt/abbreviations.py
+++ b/homeassistant/components/mqtt/abbreviations.py
@@ -23,6 +23,7 @@ ABBREVIATIONS = {
"clrm_stat_t": "color_mode_state_topic",
"clrm_val_tpl": "color_mode_value_template",
"clr_temp_cmd_t": "color_temp_command_topic",
+ "clr_temp_k": "color_temp_kelvin",
"clr_temp_stat_t": "color_temp_state_topic",
"clr_temp_tpl": "color_temp_template",
"clr_temp_val_tpl": "color_temp_value_template",
@@ -92,6 +93,8 @@ ABBREVIATIONS = {
"min_hum": "min_humidity",
"max_mirs": "max_mireds",
"min_mirs": "min_mireds",
+ "max_k": "max_kelvin",
+ "min_k": "min_kelvin",
"max_temp": "max_temp",
"min_temp": "min_temp",
"migr_discvry": "migrate_discovery",
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index 613f665c302..7bdc13d0522 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -6,7 +6,7 @@ import logging
import voluptuous as vol
-import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.components import alarm_control_panel as alarm
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
@@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index b49dc7aa24c..d736123eae8 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -26,9 +26,8 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, event as evt
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.event as evt
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py
index 8e5446b532e..b6056c2efd9 100644
--- a/homeassistant/components/mqtt/button.py
+++ b/homeassistant/components/mqtt/button.py
@@ -9,7 +9,7 @@ from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index 6500c9f91c9..16a02e4956e 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -220,8 +220,7 @@ def async_subscribe_internal(
mqtt_data = hass.data[DATA_MQTT]
except KeyError as exc:
raise HomeAssistantError(
- f"Cannot subscribe to topic '{topic}', "
- "make sure MQTT is set up correctly",
+ f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly",
translation_key="mqtt_not_setup_cannot_subscribe",
translation_domain=DOMAIN,
translation_placeholders={"topic": topic},
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
index e62303472ed..12619609f64 100644
--- a/homeassistant/components/mqtt/climate.py
+++ b/homeassistant/components/mqtt/climate.py
@@ -44,7 +44,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index 0081246c705..a9d417fc783 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -76,6 +76,8 @@ from .const import (
CONF_WILL_MESSAGE,
CONF_WS_HEADERS,
CONF_WS_PATH,
+ CONFIG_ENTRY_MINOR_VERSION,
+ CONFIG_ENTRY_VERSION,
DEFAULT_BIRTH,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
@@ -205,7 +207,9 @@ def update_password_from_user_input(
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
- VERSION = 1
+ # Can be bumped to version 2.1 with HA Core 2026.1.0
+ VERSION = CONFIG_ENTRY_VERSION # 1
+ MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2
_hassio_discovery: dict[str, Any] | None = None
_addon_manager: AddonManager
@@ -481,7 +485,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors,
):
if is_reconfigure:
- update_password_from_user_input(
+ validated_user_input = update_password_from_user_input(
reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input
)
@@ -496,7 +500,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
reconfigure_entry,
data=validated_user_input,
)
- validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY
return self.async_create_entry(
title=validated_user_input[CONF_BROKER],
data=validated_user_input,
@@ -564,58 +567,17 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
class MQTTOptionsFlowHandler(OptionsFlow):
"""Handle MQTT options."""
- def __init__(self) -> None:
- """Initialize MQTT options flow."""
- self.broker_config: dict[str, Any] = {}
-
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:
"""Manage the MQTT options."""
- return await self.async_step_broker()
-
- async def async_step_broker(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Manage the MQTT broker configuration."""
- errors: dict[str, str] = {}
- fields: OrderedDict[Any, Any] = OrderedDict()
- validated_user_input: dict[str, Any] = {}
- if await async_get_broker_settings(
- self,
- fields,
- self.config_entry.data,
- user_input,
- validated_user_input,
- errors,
- ):
- self.broker_config.update(
- update_password_from_user_input(
- self.config_entry.data.get(CONF_PASSWORD), validated_user_input
- ),
- )
- can_connect = await self.hass.async_add_executor_job(
- try_connection,
- self.broker_config,
- )
-
- if can_connect:
- return await self.async_step_options()
-
- errors["base"] = "cannot_connect"
-
- return self.async_show_form(
- step_id="broker",
- data_schema=vol.Schema(fields),
- errors=errors,
- last_step=False,
- )
+ return await self.async_step_options()
async def async_step_options(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the MQTT options."""
errors = {}
- current_config = self.config_entry.data
- options_config: dict[str, Any] = {}
+
+ options_config: dict[str, Any] = dict(self.config_entry.options)
bad_input: bool = False
def _birth_will(birt_or_will: str) -> dict[str, Any]:
@@ -674,26 +636,18 @@ class MQTTOptionsFlowHandler(OptionsFlow):
options_config[CONF_WILL_MESSAGE] = {}
if not bad_input:
- updated_config = {}
- updated_config.update(self.broker_config)
- updated_config.update(options_config)
- self.hass.config_entries.async_update_entry(
- self.config_entry,
- data=updated_config,
- title=str(self.broker_config[CONF_BROKER]),
- )
- return self.async_create_entry(title="", data={})
+ return self.async_create_entry(data=options_config)
birth = {
**DEFAULT_BIRTH,
- **current_config.get(CONF_BIRTH_MESSAGE, {}),
+ **options_config.get(CONF_BIRTH_MESSAGE, {}),
}
will = {
**DEFAULT_WILL,
- **current_config.get(CONF_WILL_MESSAGE, {}),
+ **options_config.get(CONF_WILL_MESSAGE, {}),
}
- discovery = current_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY)
- discovery_prefix = current_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX)
+ discovery = options_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY)
+ discovery_prefix = options_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX)
# build form
fields: OrderedDict[vol.Marker, Any] = OrderedDict()
@@ -706,8 +660,8 @@ class MQTTOptionsFlowHandler(OptionsFlow):
fields[
vol.Optional(
"birth_enable",
- default=CONF_BIRTH_MESSAGE not in current_config
- or current_config[CONF_BIRTH_MESSAGE] != {},
+ default=CONF_BIRTH_MESSAGE not in options_config
+ or options_config[CONF_BIRTH_MESSAGE] != {},
)
] = BOOLEAN_SELECTOR
fields[
@@ -729,8 +683,8 @@ class MQTTOptionsFlowHandler(OptionsFlow):
fields[
vol.Optional(
"will_enable",
- default=CONF_WILL_MESSAGE not in current_config
- or current_config[CONF_WILL_MESSAGE] != {},
+ default=CONF_WILL_MESSAGE not in options_config
+ or options_config[CONF_WILL_MESSAGE] != {},
)
] = BOOLEAN_SELECTOR
fields[
@@ -814,11 +768,8 @@ async def async_get_broker_settings(
validated_user_input.update(user_input)
client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT)
client_key_id: str | None = user_input.get(CONF_CLIENT_KEY)
- if (
- client_certificate_id
- and not client_key_id
- or not client_certificate_id
- and client_key_id
+ if (client_certificate_id and not client_key_id) or (
+ not client_certificate_id and client_key_id
):
errors["base"] = "invalid_inclusion"
return False
@@ -828,14 +779,20 @@ async def async_get_broker_settings(
# Return to form for file upload CA cert or client cert and key
if (
- not client_certificate
- and user_input.get(SET_CLIENT_CERT)
- and not client_certificate_id
- or not certificate
- and user_input.get(SET_CA_CERT, "off") == "custom"
- and not certificate_id
- or user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS
- and CONF_WS_PATH not in user_input
+ (
+ not client_certificate
+ and user_input.get(SET_CLIENT_CERT)
+ and not client_certificate_id
+ )
+ or (
+ not certificate
+ and user_input.get(SET_CA_CERT, "off") == "custom"
+ and not certificate_id
+ )
+ or (
+ user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS
+ and CONF_WS_PATH not in user_input
+ )
):
return False
diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py
index 9f1c55a54e0..007b3b7e576 100644
--- a/homeassistant/components/mqtt/const.py
+++ b/homeassistant/components/mqtt/const.py
@@ -4,7 +4,7 @@ import logging
import jinja2
-from homeassistant.const import CONF_PAYLOAD, Platform
+from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform
from homeassistant.exceptions import TemplateError
ATTR_DISCOVERY_HASH = "discovery_hash"
@@ -56,12 +56,15 @@ CONF_SUPPORTED_FEATURES = "supported_features"
CONF_ACTION_TEMPLATE = "action_template"
CONF_ACTION_TOPIC = "action_topic"
+CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin"
CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template"
CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic"
CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template"
CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic"
CONF_ENABLED_BY_DEFAULT = "enabled_by_default"
CONF_ENTITY_PICTURE = "entity_picture"
+CONF_MAX_KELVIN = "max_kelvin"
+CONF_MIN_KELVIN = "min_kelvin"
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
CONF_MODE_COMMAND_TOPIC = "mode_command_topic"
CONF_MODE_LIST = "modes"
@@ -160,6 +163,20 @@ MQTT_CONNECTION_STATE = "mqtt_connection_state"
PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None"
+CONFIG_ENTRY_VERSION = 1
+CONFIG_ENTRY_MINOR_VERSION = 2
+
+# Split mqtt entry data and options
+# Can be removed when config entry is bumped to version 2.1
+# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
+# From 2026.1 we will write version 2.1
+ENTRY_OPTION_FIELDS = (
+ CONF_DISCOVERY,
+ CONF_DISCOVERY_PREFIX,
+ "birth_message",
+ "will_message",
+)
+
ENTITY_PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
index c7d041848f0..626e0cef64a 100644
--- a/homeassistant/components/mqtt/cover.py
+++ b/homeassistant/components/mqtt/cover.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
STATE_OPENING,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
index bdf543e046a..d3ad57ef43d 100644
--- a/homeassistant/components/mqtt/device_tracker.py
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
STATE_NOT_HOME,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py
index 8104c37574b..7a17c1f3409 100644
--- a/homeassistant/components/mqtt/diagnostics.py
+++ b/homeassistant/components/mqtt/diagnostics.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+from typing import Any
from homeassistant.components import device_tracker
from homeassistant.components.diagnostics import async_redact_data
@@ -18,7 +18,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
from . import debug_info, is_connected
-from .models import DATA_MQTT
REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME}
REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE}
@@ -45,11 +44,10 @@ def _async_get_diagnostics(
device: DeviceEntry | None = None,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- mqtt_instance = hass.data[DATA_MQTT].client
- if TYPE_CHECKING:
- assert mqtt_instance is not None
-
- redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG)
+ redacted_config = {
+ "data": async_redact_data(dict(entry.data), REDACT_CONFIG),
+ "options": dict(entry.options),
+ }
data = {
"connected": is_connected(hass),
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
index a5ddb3ef4e6..a14240ce008 100644
--- a/homeassistant/components/mqtt/discovery.py
+++ b/homeassistant/components/mqtt/discovery.py
@@ -21,8 +21,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_DEVICE, CONF_PLATFORM
from homeassistant.core import HassJobType, HomeAssistant, callback
-from homeassistant.helpers import discovery_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@@ -138,7 +137,10 @@ def get_origin_log_string(
support_url_log = ""
if include_url and (support_url := get_origin_support_url(discovery_payload)):
support_url_log = f", support URL: {support_url}"
- return f" from external application {origin_info["name"]}{sw_version_log}{support_url_log}"
+ return (
+ " from external application "
+ f"{origin_info['name']}{sw_version_log}{support_url_log}"
+ )
@callback
@@ -383,7 +385,7 @@ async def async_start( # noqa: C901
_async_add_component(discovery_payload)
@callback
- def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901
+ def async_discovery_message_received(msg: ReceiveMessage) -> None:
"""Process the received message."""
mqtt_data.last_discovery = msg.timestamp
payload = msg.payload
diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py
index d9812aaaf48..5855f94dad7 100644
--- a/homeassistant/components/mqtt/event.py
+++ b/homeassistant/components/mqtt/event.py
@@ -17,7 +17,7 @@ from homeassistant.components.event import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -151,7 +151,7 @@ class MqttEvent(MqttEntity, EventEntity):
)
except KeyError:
_LOGGER.warning(
- ("`event_type` missing in JSON event payload, " " '%s' on topic %s"),
+ "`event_type` missing in JSON event payload, '%s' on topic %s",
payload,
msg.topic,
)
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
index 4d2e764a0d5..d8e96eb2734 100644
--- a/homeassistant/components/mqtt/fan.py
+++ b/homeassistant/components/mqtt/fan.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
CONF_STATE,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py
index 5d1af03ad24..bffe0ec1420 100644
--- a/homeassistant/components/mqtt/humidifier.py
+++ b/homeassistant/components/mqtt/humidifier.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
CONF_STATE,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py
index 159a23d14d9..a2f424b247d 100644
--- a/homeassistant/components/mqtt/light/schema_basic.py
+++ b/homeassistant/components/mqtt/light/schema_basic.py
@@ -42,16 +42,19 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .. import subscription
from ..config import MQTT_RW_SCHEMA
from ..const import (
+ CONF_COLOR_TEMP_KELVIN,
CONF_COMMAND_TOPIC,
+ CONF_MAX_KELVIN,
+ CONF_MIN_KELVIN,
CONF_STATE_TOPIC,
CONF_STATE_VALUE_TEMPLATE,
PAYLOAD_NONE,
@@ -182,6 +185,7 @@ PLATFORM_SCHEMA_MODERN_BASIC = (
vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean,
vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
@@ -193,6 +197,8 @@ PLATFORM_SCHEMA_MODERN_BASIC = (
vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_MAX_MIREDS): cv.positive_int,
vol.Optional(CONF_MIN_MIREDS): cv.positive_int,
+ vol.Optional(CONF_MAX_KELVIN): cv.positive_int,
+ vol.Optional(CONF_MIN_KELVIN): cv.positive_int,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In(
VALUES_ON_COMMAND_TYPE
@@ -239,6 +245,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
_topic: dict[str, str | None]
_payload: dict[str, str]
+ _color_temp_kelvin: bool
_command_templates: dict[
str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType]
]
@@ -263,16 +270,18 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
+ self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN]
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(max_mireds)
if (max_mireds := config.get(CONF_MAX_MIREDS))
- else DEFAULT_MIN_KELVIN
+ else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN)
)
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(min_mireds)
if (min_mireds := config.get(CONF_MIN_MIREDS))
- else DEFAULT_MAX_KELVIN
+ else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN)
)
+
self._attr_effect_list = config.get(CONF_EFFECT_LIST)
topic: dict[str, str | None] = {
@@ -526,6 +535,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
if self._optimistic_color_mode:
self._attr_color_mode = ColorMode.COLOR_TEMP
+ if self._color_temp_kelvin:
+ self._attr_color_temp_kelvin = int(payload)
+ return
self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin(
int(payload)
)
@@ -575,7 +587,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
self._attr_xy_color = cast(tuple[float, float], xy_color)
@callback
- def _prepare_subscribe_topics(self) -> None: # noqa: C901
+ def _prepare_subscribe_topics(self) -> None:
"""(Re)Subscribe to topics."""
self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"})
self.add_subscription(
@@ -818,7 +830,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
):
ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE]
color_temp = ct_command_tpl(
- color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ if self._color_temp_kelvin
+ else color_util.color_temperature_kelvin_to_mired(
kwargs[ATTR_COLOR_TEMP_KELVIN]
),
None,
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
index f6efdd3281d..43b0cbf77b3 100644
--- a/homeassistant/components/mqtt/light/schema_json.py
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -49,19 +49,22 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import async_get_hass, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, VolSchemaType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from homeassistant.util.json import json_loads_object
from homeassistant.util.yaml import dump as yaml_dump
from .. import subscription
from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA
from ..const import (
+ CONF_COLOR_TEMP_KELVIN,
CONF_COMMAND_TOPIC,
+ CONF_MAX_KELVIN,
+ CONF_MIN_KELVIN,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
@@ -203,6 +206,7 @@ _PLATFORM_SCHEMA_BASE = (
# CONF_COLOR_TEMP was deprecated with HA Core 2024.4 and will be
# removed with HA Core 2025.3
vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean,
+ vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean,
vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean,
vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(
@@ -216,6 +220,8 @@ _PLATFORM_SCHEMA_BASE = (
vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean,
vol.Optional(CONF_MAX_MIREDS): cv.positive_int,
vol.Optional(CONF_MIN_MIREDS): cv.positive_int,
+ vol.Optional(CONF_MAX_KELVIN): cv.positive_int,
+ vol.Optional(CONF_MIN_KELVIN): cv.positive_int,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All(
vol.Coerce(int), vol.In([0, 1, 2])
@@ -275,15 +281,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
+ self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN]
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(max_mireds)
if (max_mireds := config.get(CONF_MAX_MIREDS))
- else DEFAULT_MIN_KELVIN
+ else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN)
)
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(min_mireds)
if (min_mireds := config.get(CONF_MIN_MIREDS))
- else DEFAULT_MAX_KELVIN
+ else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN)
)
self._attr_effect_list = config.get(CONF_EFFECT_LIST)
@@ -381,7 +388,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
try:
if color_mode == ColorMode.COLOR_TEMP:
self._attr_color_temp_kelvin = (
- color_util.color_temperature_mired_to_kelvin(
+ values["color_temp"]
+ if self._color_temp_kelvin
+ else color_util.color_temperature_mired_to_kelvin(
values["color_temp"]
)
)
@@ -486,7 +495,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
self._attr_color_temp_kelvin = None
else:
self._attr_color_temp_kelvin = (
- color_util.color_temperature_mired_to_kelvin(
+ values["color_temp"] # type: ignore[assignment]
+ if self._color_temp_kelvin
+ else color_util.color_temperature_mired_to_kelvin(
values["color_temp"] # type: ignore[arg-type]
)
)
@@ -709,10 +720,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
should_update = True
if ATTR_COLOR_TEMP_KELVIN in kwargs:
- message["color_temp"] = color_util.color_temperature_kelvin_to_mired(
+ message["color_temp"] = (
kwargs[ATTR_COLOR_TEMP_KELVIN]
+ if self._color_temp_kelvin
+ else color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
)
-
if self._optimistic:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
index 722bd864366..901cee6f14c 100644
--- a/homeassistant/components/mqtt/light/schema_template.py
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -31,15 +31,22 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolSchemaType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .. import subscription
from ..config import MQTT_RW_SCHEMA
-from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE
+from ..const import (
+ CONF_COLOR_TEMP_KELVIN,
+ CONF_COMMAND_TOPIC,
+ CONF_MAX_KELVIN,
+ CONF_MIN_KELVIN,
+ CONF_STATE_TOPIC,
+ PAYLOAD_NONE,
+)
from ..entity import MqttEntity
from ..models import (
MqttCommandTemplate,
@@ -85,12 +92,15 @@ PLATFORM_SCHEMA_MODERN_TEMPLATE = (
{
vol.Optional(CONF_BLUE_TEMPLATE): cv.template,
vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template,
+ vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean,
vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template,
vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template,
vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template,
vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_EFFECT_TEMPLATE): cv.template,
vol.Optional(CONF_GREEN_TEMPLATE): cv.template,
+ vol.Optional(CONF_MAX_KELVIN): cv.positive_int,
+ vol.Optional(CONF_MIN_KELVIN): cv.positive_int,
vol.Optional(CONF_MAX_MIREDS): cv.positive_int,
vol.Optional(CONF_MIN_MIREDS): cv.positive_int,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
@@ -128,15 +138,16 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
+ self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN]
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(max_mireds)
if (max_mireds := config.get(CONF_MAX_MIREDS))
- else DEFAULT_MIN_KELVIN
+ else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN)
)
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(min_mireds)
if (min_mireds := config.get(CONF_MIN_MIREDS))
- else DEFAULT_MAX_KELVIN
+ else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN)
)
self._attr_effect_list = config.get(CONF_EFFECT_LIST)
@@ -224,7 +235,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
msg.payload
)
self._attr_color_temp_kelvin = (
- color_util.color_temperature_mired_to_kelvin(int(color_temp))
+ int(color_temp)
+ if self._color_temp_kelvin
+ else color_util.color_temperature_mired_to_kelvin(int(color_temp))
if color_temp != "None"
else None
)
@@ -310,8 +323,12 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
- values["color_temp"] = color_util.color_temperature_kelvin_to_mired(
+ values["color_temp"] = (
kwargs[ATTR_COLOR_TEMP_KELVIN]
+ if self._color_temp_kelvin
+ else color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
)
if self._optimistic:
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index 2113dbbd5ba..895bfba3560 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json
index 081449b142a..25e98c01aaf 100644
--- a/homeassistant/components/mqtt/manifest.json
+++ b/homeassistant/components/mqtt/manifest.json
@@ -7,6 +7,7 @@
"dependencies": ["file_upload", "http"],
"documentation": "https://www.home-assistant.io/integrations/mqtt",
"iot_class": "local_push",
+ "quality_scale": "platinum",
"requirements": ["paho-mqtt==1.6.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py
index 84442e75e73..7e0a7fd4dd8 100644
--- a/homeassistant/components/mqtt/notify.py
+++ b/homeassistant/components/mqtt/notify.py
@@ -9,7 +9,7 @@ from homeassistant.components.notify import NotifyEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml
index 26ce8cb08dd..b17812acd91 100644
--- a/homeassistant/components/mqtt/quality_scale.yaml
+++ b/homeassistant/components/mqtt/quality_scale.yaml
@@ -89,10 +89,7 @@ rules:
comment: >
This is not possible because the integrations generates entities
based on a user supplied config or discovery.
- reconfiguration-flow:
- status: done
- comment: >
- This integration can also be reconfigured via options flow.
+ reconfiguration-flow: done
dynamic-devices:
status: done
comment: |
@@ -126,6 +123,7 @@ rules:
comment: |
This integration does not use web sessions.
strict-typing:
- status: todo
- comment: |
- Requirement 'paho-mqtt==1.6.1' appears untyped
+ status: done
+ comment: >
+ Typing for 'paho-mqtt==1.6.1' supported via 'types-paho-mqtt==1.6.0.20240321'
+ (requirements_test.txt).
diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py
index 314bd716ee0..c6651510a36 100644
--- a/homeassistant/components/mqtt/scene.py
+++ b/homeassistant/components/mqtt/scene.py
@@ -11,7 +11,7 @@ from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index bacbf4d323e..ad84ebb09a3 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py
index 22f64053d23..5e3ca76e722 100644
--- a/homeassistant/components/mqtt/siren.py
+++ b/homeassistant/components/mqtt/siren.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
CONF_PAYLOAD_ON,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
@@ -217,10 +217,7 @@ class MqttSiren(MqttEntity, SirenEntity):
try:
json_payload = json_loads_object(payload)
_LOGGER.debug(
- (
- "JSON payload detected after processing payload '%s' on"
- " topic %s"
- ),
+ "JSON payload detected after processing payload '%s' on topic %s",
json_payload,
msg.topic,
)
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index 3b337c05d2a..3815b6adbd5 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -11,14 +11,6 @@
"invalid_platform_config": {
"title": "Invalid config found for mqtt {domain} item",
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
- },
- "payload_template_deprecation": {
- "title": "Deprecated option used in mqtt publish action call",
- "description": "Deprecated `payload_template` option used in MQTT publish action call to topic `{topic}` from payload template `{payload_template}`. Use the `payload` option instead. In automations templates are supported natively. Update the automation or script to use the `payload` option instead and restart Home Assistant to fix this issue."
- },
- "topic_template_deprecation": {
- "title": "Deprecated option used in mqtt publish action call",
- "description": "Deprecated `topic_template` option used in MQTT publish action call to topic `{topic}` from topic template `{topic_template}`. Use the `topic` option instead. In automations templates are supported natively. Update the automation or script to use the `topic` option instead and restart Home Assistant to fix this issue."
}
},
"config": {
@@ -209,7 +201,7 @@
"birth_payload": "The `birth` message that is published when MQTT is ready and connected.",
"birth_qos": "The quality of service of the `birth` message that is published when MQTT is ready and connected",
"birth_retain": "When set, Home Assistant will retain the `birth` message published to your MQTT broker.",
- "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it looses the connection to your broker.",
+ "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it loses the connection to your broker.",
"will_topic": "The MQTT topic your MQTT broker will publish a `will` message to.",
"will_payload": "The message your MQTT broker `will` publish when the MQTT integration is stopped or when the connection is lost.",
"will_qos": "The quality of service of the `will` message that is published by your MQTT broker.",
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
index 0a54bcdb378..a305fa83485 100644
--- a/homeassistant/components/mqtt/switch.py
+++ b/homeassistant/components/mqtt/switch.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py
index 680f252fb20..9a05d1896f7 100644
--- a/homeassistant/components/mqtt/tag.py
+++ b/homeassistant/components/mqtt/tag.py
@@ -12,7 +12,7 @@ from homeassistant.components import tag
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE
from homeassistant.core import HassJobType, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py
index 99b4e5cb821..59742d24b60 100644
--- a/homeassistant/components/mqtt/update.py
+++ b/homeassistant/components/mqtt/update.py
@@ -151,10 +151,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity):
rendered_json_payload = json_loads(payload)
if isinstance(rendered_json_payload, dict):
_LOGGER.debug(
- (
- "JSON payload detected after processing payload '%s' on"
- " topic %s"
- ),
+ "JSON payload detected after processing payload '%s' on topic %s",
rendered_json_payload,
msg.topic,
)
diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py
index 743bfb363f3..ae6b25eff14 100644
--- a/homeassistant/components/mqtt/vacuum.py
+++ b/homeassistant/components/mqtt/vacuum.py
@@ -17,7 +17,7 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType
diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py
index 50c5960f801..b380199332b 100644
--- a/homeassistant/components/mqtt/valve.py
+++ b/homeassistant/components/mqtt/valve.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py
index 4c1d3fa8a53..967eceac326 100644
--- a/homeassistant/components/mqtt/water_heater.py
+++ b/homeassistant/components/mqtt/water_heater.py
@@ -35,7 +35,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolSchemaType
diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py
index 5e677d13cfe..20602e03f81 100644
--- a/homeassistant/components/mqtt_eventstream/__init__.py
+++ b/homeassistant/components/mqtt_eventstream/__init__.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
MATCH_ALL,
)
from homeassistant.core import EventOrigin, HomeAssistant, State, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py
index 3200da56cf6..6f4e83799d1 100644
--- a/homeassistant/components/mqtt_json/device_tracker.py
+++ b/homeassistant/components/mqtt_json/device_tracker.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_DEVICES,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py
index 849d4562423..242c39cb983 100644
--- a/homeassistant/components/mqtt_room/sensor.py
+++ b/homeassistant/components/mqtt_room/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
STATE_NOT_HOME,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py
index 3a0953a0158..9a08fa2c73a 100644
--- a/homeassistant/components/mqtt_statestream/__init__.py
+++ b/homeassistant/components/mqtt_statestream/__init__.py
@@ -9,7 +9,7 @@ from homeassistant.components import mqtt
from homeassistant.components.mqtt import valid_publish_topic
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import (
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
convert_include_exclude_filter,
diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py
index a4de5d126d5..06f9bc42e91 100644
--- a/homeassistant/components/msteams/notify.py
+++ b/homeassistant/components/msteams/notify.py
@@ -16,7 +16,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index 052f4f556c1..e569bb93a42 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -15,9 +15,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py
index f3297bf0a6f..bcd33b7fd6c 100644
--- a/homeassistant/components/music_assistant/actions.py
+++ b/homeassistant/components/music_assistant/actions.py
@@ -16,7 +16,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_ALBUM_ARTISTS_ONLY,
diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py
index fc50a2d654b..b00924c97a5 100644
--- a/homeassistant/components/music_assistant/config_flow.py
+++ b/homeassistant/components/music_assistant/config_flow.py
@@ -13,11 +13,11 @@ from music_assistant_client.exceptions import (
from music_assistant_models.api import ServerInfoMessage
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
@@ -93,7 +93,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Mass server.
diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py
index 9aa7498a2ee..4a7e20046b2 100644
--- a/homeassistant/components/music_assistant/media_player.py
+++ b/homeassistant/components/music_assistant/media_player.py
@@ -39,8 +39,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import ATTR_NAME, STATE_OFF
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py
index 9caae2ee0b4..d8c4fe1649d 100644
--- a/homeassistant/components/music_assistant/schemas.py
+++ b/homeassistant/components/music_assistant/schemas.py
@@ -8,7 +8,7 @@ from music_assistant_models.enums import MediaType
import voluptuous as vol
from homeassistant.const import ATTR_NAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_ACTIVE,
diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json
index af366c94310..32b72088518 100644
--- a/homeassistant/components/music_assistant/strings.json
+++ b/homeassistant/components/music_assistant/strings.json
@@ -7,15 +7,15 @@
}
},
"manual": {
- "title": "Manually add Music Assistant Server",
- "description": "Enter the URL to your already running Music Assistant Server. If you do not have the Music Assistant Server running, you should install it first.",
+ "title": "Manually add Music Assistant server",
+ "description": "Enter the URL to your already running Music Assistant server. If you do not have the Music Assistant server running, you should install it first.",
"data": {
"url": "URL of the Music Assistant server"
}
},
"discovery_confirm": {
- "description": "Do you want to add the Music Assistant Server `{url}` to Home Assistant?",
- "title": "Discovered Music Assistant Server"
+ "description": "Do you want to add the Music Assistant server `{url}` to Home Assistant?",
+ "title": "Discovered Music Assistant server"
}
},
"error": {
@@ -34,13 +34,13 @@
"issues": {
"invalid_server_version": {
"title": "The Music Assistant server is not the correct version",
- "description": "Check if there are updates available for the Music Assistant Server and/or integration."
+ "description": "Check if there are updates available for the Music Assistant server and/or integration."
}
},
"services": {
"play_media": {
"name": "Play media",
- "description": "Play media on a Music Assistant player with more fine-grained control options.",
+ "description": "Plays media on a Music Assistant player with more fine-grained control options.",
"fields": {
"media_id": {
"name": "Media ID(s)",
@@ -70,7 +70,7 @@
},
"play_announcement": {
"name": "Play announcement",
- "description": "Play announcement on a Music Assistant player with more fine-grained control options.",
+ "description": "Plays an announcement on a Music Assistant player with more fine-grained control options.",
"fields": {
"url": {
"name": "URL",
@@ -88,7 +88,7 @@
},
"transfer_queue": {
"name": "Transfer queue",
- "description": "Transfer the player's queue to another player.",
+ "description": "Transfers a player's queue to another player.",
"fields": {
"source_player": {
"name": "Source media player",
@@ -102,11 +102,11 @@
},
"get_queue": {
"name": "Get playerQueue details (advanced)",
- "description": "Get the details of the currently active queue of a Music Assistant player."
+ "description": "Retrieves the details of the currently active queue of a Music Assistant player."
},
"search": {
"name": "Search Music Assistant",
- "description": "Perform a global search on the Music Assistant library and all providers.",
+ "description": "Performs a global search on the Music Assistant library and all providers.",
"fields": {
"config_entry_id": {
"name": "Music Assistant instance",
diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py
index b482de8130c..d8b43517711 100644
--- a/homeassistant/components/mvglive/sensor.py
+++ b/homeassistant/components/mvglive/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/mycroft/__init__.py b/homeassistant/components/mycroft/__init__.py
index 557eca972e6..e5893e57a8e 100644
--- a/homeassistant/components/mycroft/__init__.py
+++ b/homeassistant/components/mycroft/__init__.py
@@ -4,8 +4,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
DOMAIN = "mycroft"
diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py
index f3fb03ffac8..e616e325835 100644
--- a/homeassistant/components/mysensors/config_flow.py
+++ b/homeassistant/components/mysensors/config_flow.py
@@ -20,8 +20,7 @@ from homeassistant.components.mqtt import (
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.core import callback
-from homeassistant.helpers import selector
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.typing import VolDictType
from .const import (
diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py
index fa3464c0088..bdc83f30b21 100644
--- a/homeassistant/components/mysensors/gateway.py
+++ b/homeassistant/components/mysensors/gateway.py
@@ -22,7 +22,7 @@ from homeassistant.components.mqtt import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util.unit_system import METRIC_SYSTEM
diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py
index 74dc99e76d3..c96ad6cea8e 100644
--- a/homeassistant/components/mysensors/helpers.py
+++ b/homeassistant/components/mysensors/helpers.py
@@ -14,7 +14,7 @@ import voluptuous as vol
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.decorator import Registry
diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py
index f4de18aa0ef..58ac6051c8a 100644
--- a/homeassistant/components/mythicbeastsdns/__init__.py
+++ b/homeassistant/components/mythicbeastsdns/__init__.py
@@ -12,8 +12,8 @@ from homeassistant.const import (
CONF_SCAN_INTERVAL,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
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.helpers.typing import ConfigType
diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py
index bd875d8a872..5751d574e04 100644
--- a/homeassistant/components/myuplink/helpers.py
+++ b/homeassistant/components/myuplink/helpers.py
@@ -26,10 +26,8 @@ def find_matching_platform(
if len(device_point.enum_values) > 0 and device_point.writable:
return Platform.SELECT
- if (
- description
- and description.native_unit_of_measurement == "DM"
- or (device_point.raw["maxValue"] and device_point.raw["minValue"])
+ if (description and description.native_unit_of_measurement == "DM") or (
+ device_point.raw["maxValue"] and device_point.raw["minValue"]
):
if device_point.writable:
return Platform.NUMBER
diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json
index 8438d24194c..d3242115acb 100644
--- a/homeassistant/components/myuplink/manifest.json
+++ b/homeassistant/components/myuplink/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/myuplink",
"iot_class": "cloud_polling",
"quality_scale": "silver",
- "requirements": ["myuplink==0.6.0"]
+ "requirements": ["myuplink==0.7.0"]
}
diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py
index e3c22b42d28..c1efa18f72b 100644
--- a/homeassistant/components/nad/media_player.py
+++ b/homeassistant/components/nad/media_player.py
@@ -13,7 +13,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py
index 494ce9fdac0..fa94971e2ef 100644
--- a/homeassistant/components/nam/config_flow.py
+++ b/homeassistant/components/nam/config_flow.py
@@ -17,12 +17,12 @@ from nettigo_air_monitor import (
)
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -138,7 +138,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.host = discovery_info.host
diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py
index 43310c5e922..7fbd49d979b 100644
--- a/homeassistant/components/namecheapdns/__init__.py
+++ b/homeassistant/components/namecheapdns/__init__.py
@@ -8,8 +8,8 @@ import voluptuous as vol
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
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.helpers.typing import ConfigType
diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py
index 27ef9a887fe..253387c254a 100644
--- a/homeassistant/components/nanoleaf/config_flow.py
+++ b/homeassistant/components/nanoleaf/config_flow.py
@@ -10,11 +10,15 @@ from typing import Any, Final, cast
from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable
import voluptuous as vol
-from homeassistant.components import ssdp, zeroconf
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.json import save_json
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo,
+)
from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object
from .const import DOMAIN
@@ -86,31 +90,31 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_link()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle Nanoleaf Zeroconf discovery."""
_LOGGER.debug("Zeroconf discovered: %s", discovery_info)
return await self._async_homekit_zeroconf_discovery_handler(discovery_info)
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle Nanoleaf Homekit discovery."""
_LOGGER.debug("Homekit discovered: %s", discovery_info)
return await self._async_homekit_zeroconf_discovery_handler(discovery_info)
async def _async_homekit_zeroconf_discovery_handler(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle Nanoleaf Homekit and Zeroconf discovery."""
return await self._async_discovery_handler(
discovery_info.host,
discovery_info.name.replace(f".{discovery_info.type}", ""),
- discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID],
+ discovery_info.properties[ATTR_PROPERTIES_ID],
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle Nanoleaf SSDP discovery."""
_LOGGER.debug("SSDP discovered: %s", discovery_info)
diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json
index b8af8cd54db..8b93ea10d79 100644
--- a/homeassistant/components/nasweb/strings.json
+++ b/homeassistant/components/nasweb/strings.json
@@ -14,9 +14,9 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "missing_internal_url": "Make sure Home Assistant has valid internal url",
- "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant.",
- "missing_status": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device.",
+ "missing_internal_url": "Make sure Home Assistant has a valid internal URL",
+ "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.",
+ "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
@@ -25,13 +25,13 @@
},
"exceptions": {
"config_entry_error_invalid_authentication": {
- "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create new one with correct username/password."
+ "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password."
},
"config_entry_error_internal_error": {
- "message": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant. If the issue persists contact support at {support_email}"
+ "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}"
},
"config_entry_error_no_status_update": {
- "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}"
+ "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}"
},
"config_entry_error_missing_internal_url": {
"message": "[%key:component::nasweb::config::error::missing_internal_url%]"
diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py
index 00e5a21da18..c5a9e085b83 100644
--- a/homeassistant/components/nasweb/switch.py
+++ b/homeassistant/components/nasweb/switch.py
@@ -10,9 +10,9 @@ from webio_api import Output as NASwebOutput
from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json
index e2c983167b1..0324fdb8fad 100644
--- a/homeassistant/components/neato/strings.json
+++ b/homeassistant/components/neato/strings.json
@@ -42,24 +42,24 @@
},
"services": {
"custom_cleaning": {
- "name": "Zone cleaning action",
- "description": "Zone cleaning action specific to Neato Botvacs.",
+ "name": "Custom cleaning",
+ "description": "Starts a custom cleaning of your house.",
"fields": {
"mode": {
- "name": "Set cleaning mode",
- "description": "Set the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set."
+ "name": "Cleaning mode",
+ "description": "Sets the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set."
},
"navigation": {
- "name": "Set navigation mode",
- "description": "Set the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set."
+ "name": "Navigation mode",
+ "description": "Sets the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set."
},
"category": {
"name": "Use cleaning map",
"description": "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found)."
},
"zone": {
- "name": "Name of the zone to clean (Only Botvac D7)",
- "description": "Only supported on the Botvac D7. Name of the zone to clean. Defaults to no zone i.e. complete house cleanup."
+ "name": "Zone",
+ "description": "Name of the zone to clean (only supported on the Botvac D7). Defaults to no zone i.e. complete house cleanup."
}
}
}
diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py
index ce3e7d3a002..ff3eea9252c 100644
--- a/homeassistant/components/nederlandse_spoorwegen/sensor.py
+++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index 0bd2891914f..8adc0e4f714 100644
--- a/homeassistant/components/nest/__init__.py
+++ b/homeassistant/components/nest/__init__.py
@@ -27,7 +27,6 @@ from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.components.camera import Image, img_util
from homeassistant.components.http import KEY_HASS_USER
from homeassistant.components.http.view import HomeAssistantView
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_BINARY_SENSORS,
CONF_CLIENT_ID,
@@ -55,6 +54,7 @@ from homeassistant.helpers.typing import ConfigType
from . import api
from .const import (
+ CONF_CLOUD_PROJECT_ID,
CONF_PROJECT_ID,
CONF_SUBSCRIBER_ID,
CONF_SUBSCRIBER_ID_IMPORTED,
@@ -214,33 +214,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
update_callback = SignalUpdateCallback(hass, async_config_reload, entry)
subscriber.set_update_callback(update_callback.async_handle_event)
try:
- await subscriber.start_async()
+ unsub = await subscriber.start_async()
except AuthException as err:
raise ConfigEntryAuthFailed(
f"Subscriber authentication error: {err!s}"
) from err
except ConfigurationException as err:
_LOGGER.error("Configuration error: %s", err)
- subscriber.stop_async()
return False
except SubscriberException as err:
- subscriber.stop_async()
raise ConfigEntryNotReady(f"Subscriber error: {err!s}") from err
try:
device_manager = await subscriber.async_get_device_manager()
except ApiException as err:
- subscriber.stop_async()
+ unsub()
raise ConfigEntryNotReady(f"Device manager error: {err!s}") from err
@callback
def on_hass_stop(_: Event) -> None:
"""Close connection when hass stops."""
- subscriber.stop_async()
+ unsub()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
+
+ entry.async_on_unload(unsub)
entry.runtime_data = NestData(
subscriber=subscriber,
device_manager=device_manager,
@@ -251,18 +251,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool:
"""Unload a config entry."""
- if DATA_SDM not in entry.data:
- # Legacy API
- return True
- _LOGGER.debug("Stopping nest subscriber")
- subscriber = entry.runtime_data.subscriber
- subscriber.stop_async()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_remove_entry(hass: HomeAssistant, entry: NestConfigEntry) -> None:
"""Handle removal of pubsub subscriptions created during config flow."""
if (
DATA_SDM not in entry.data
@@ -272,24 +266,25 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
or CONF_SUBSCRIBER_ID_IMPORTED in entry.data
):
return
-
- subscriber = await api.new_subscriber(hass, entry)
- if not subscriber:
- return
- _LOGGER.debug("Deleting subscriber '%s'", subscriber.subscriber_id)
+ if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None:
+ subscription_name = entry.data[CONF_SUBSCRIBER_ID]
+ admin_client = api.new_pubsub_admin_client(
+ hass,
+ access_token=entry.data["token"]["access_token"],
+ cloud_project_id=entry.data[CONF_CLOUD_PROJECT_ID],
+ )
+ _LOGGER.debug("Deleting subscription '%s'", subscription_name)
try:
- await subscriber.delete_subscription()
- except (AuthException, SubscriberException) as err:
+ await admin_client.delete_subscription(subscription_name)
+ except ApiException as err:
_LOGGER.warning(
(
"Unable to delete subscription '%s'; Will be automatically cleaned up"
" by cloud console: %s"
),
- subscriber.subscriber_id,
+ subscription_name,
err,
)
- finally:
- subscriber.stop_async()
class NestEventViewBase(HomeAssistantView, ABC):
diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py
index 5c65a70c75d..e86e326b1c2 100644
--- a/homeassistant/components/nest/api.py
+++ b/homeassistant/components/nest/api.py
@@ -12,7 +12,6 @@ from google_nest_sdm.admin_client import PUBSUB_API_HOST, AdminClient
from google_nest_sdm.auth import AbstractAuth
from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
@@ -24,6 +23,7 @@ from .const import (
OAUTH2_TOKEN,
SDM_SCOPES,
)
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -102,7 +102,7 @@ class AccessTokenAuthImpl(AbstractAuth):
async def new_subscriber(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: NestConfigEntry
) -> GoogleNestSubscriber | None:
"""Create a GoogleNestSubscriber."""
implementation = (
diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py
index d5ad28c2dfd..3193d592120 100644
--- a/homeassistant/components/nest/climate.py
+++ b/homeassistant/components/nest/climate.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any, cast
from google_nest_sdm.device import Device
-from google_nest_sdm.device_traits import FanTrait, TemperatureTrait
+from google_nest_sdm.device_traits import FanTrait, HumidityTrait, TemperatureTrait
from google_nest_sdm.exceptions import ApiException
from google_nest_sdm.thermostat_traits import (
ThermostatEcoTrait,
@@ -133,6 +133,14 @@ class ThermostatEntity(ClimateEntity):
trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME]
return trait.ambient_temperature_celsius
+ @property
+ def current_humidity(self) -> float | None:
+ """Return the current humidity."""
+ if HumidityTrait.NAME not in self._device.traits:
+ return None
+ trait: HumidityTrait = self._device.traits[HumidityTrait.NAME]
+ return trait.ambient_humidity_percent
+
@property
def target_temperature(self) -> float | None:
"""Return the temperature currently set to be reached."""
diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py
index 274e4c288b4..0b249db7a4b 100644
--- a/homeassistant/components/nest/config_flow.py
+++ b/homeassistant/components/nest/config_flow.py
@@ -15,6 +15,7 @@ import logging
from typing import TYPE_CHECKING, Any
from google_nest_sdm.admin_client import (
+ DEFAULT_TOPIC_IAM_POLICY,
AdminClient,
EligibleSubscriptions,
EligibleTopics,
@@ -25,6 +26,11 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
from homeassistant.util import get_random_string
from . import api
@@ -41,8 +47,9 @@ from .const import (
)
DATA_FLOW_IMPL = "nest_flow_implementation"
+TOPIC_FORMAT = "projects/{cloud_project_id}/topics/home-assistant-{rnd}"
SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}"
-SUBSCRIPTION_RAND_LENGTH = 10
+RAND_LENGTH = 10
MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration"
@@ -59,6 +66,7 @@ DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/"
DEVICE_ACCESS_CONSOLE_EDIT_URL = (
"https://console.nest.google.com/device-access/project/{project_id}/information"
)
+CREATE_NEW_TOPIC_KEY = "create_new_topic"
CREATE_NEW_SUBSCRIPTION_KEY = "create_new_subscription"
_LOGGER = logging.getLogger(__name__)
@@ -66,10 +74,16 @@ _LOGGER = logging.getLogger(__name__)
def _generate_subscription_id(cloud_project_id: str) -> str:
"""Create a new subscription id."""
- rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH)
+ rnd = get_random_string(RAND_LENGTH)
return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd)
+def _generate_topic_id(cloud_project_id: str) -> str:
+ """Create a new topic id."""
+ rnd = get_random_string(RAND_LENGTH)
+ return TOPIC_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd)
+
+
def generate_config_title(structures: Iterable[Structure]) -> str | None:
"""Pick a user friendly config title based on the Google Home name(s)."""
names: list[str] = [
@@ -130,7 +144,7 @@ class NestFlowHandler(
if self.source == SOURCE_REAUTH:
_LOGGER.debug("Skipping Pub/Sub configuration")
return await self._async_finish()
- return await self.async_step_pubsub()
+ return await self.async_step_pubsub_topic()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -192,7 +206,9 @@ class NestFlowHandler(
) -> ConfigFlowResult:
"""Handle cloud project in user input."""
if user_input is not None:
- self._data.update(user_input)
+ self._data[CONF_CLOUD_PROJECT_ID] = user_input[
+ CONF_CLOUD_PROJECT_ID
+ ].strip()
return await self.async_step_device_project()
return self.async_show_form(
step_id="cloud_project",
@@ -213,7 +229,7 @@ class NestFlowHandler(
"""Collect device access project from user input."""
errors = {}
if user_input is not None:
- project_id = user_input[CONF_PROJECT_ID]
+ project_id = user_input[CONF_PROJECT_ID].strip()
if project_id == self._data[CONF_CLOUD_PROJECT_ID]:
_LOGGER.error(
"Device Access Project ID and Cloud Project ID must not be the"
@@ -240,72 +256,83 @@ class NestFlowHandler(
errors=errors,
)
- async def async_step_pubsub(
+ async def async_step_pubsub_topic(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Configure and the pre-requisites to configure Pub/Sub topics and subscriptions."""
- data = {
- **self._data,
- **(user_input if user_input is not None else {}),
- }
- cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip()
- device_access_project_id = data[CONF_PROJECT_ID]
-
- errors: dict[str, str] = {}
- if cloud_project_id:
+ """Configure and create Pub/Sub topic."""
+ cloud_project_id = self._data[CONF_CLOUD_PROJECT_ID]
+ if self._admin_client is None:
access_token = self._data["token"]["access_token"]
self._admin_client = api.new_pubsub_admin_client(
- self.hass, access_token=access_token, cloud_project_id=cloud_project_id
+ self.hass,
+ access_token=access_token,
+ cloud_project_id=cloud_project_id,
)
- try:
- eligible_topics = await self._admin_client.list_eligible_topics(
- device_access_project_id=device_access_project_id
- )
- except ApiException as err:
- _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err)
- errors["base"] = "pubsub_api_error"
- else:
- if not eligible_topics.topic_names:
- errors["base"] = "no_pubsub_topics"
+ errors = {}
+ if user_input is not None:
+ topic_name = user_input[CONF_TOPIC_NAME]
+ if topic_name == CREATE_NEW_TOPIC_KEY:
+ topic_name = _generate_topic_id(cloud_project_id)
+ _LOGGER.debug("Creating topic %s", topic_name)
+ try:
+ await self._admin_client.create_topic(topic_name)
+ await self._admin_client.set_topic_iam_policy(
+ topic_name, DEFAULT_TOPIC_IAM_POLICY
+ )
+ except ApiException as err:
+ _LOGGER.error("Error creating Pub/Sub topic: %s", err)
+ errors["base"] = "pubsub_api_error"
if not errors:
- self._data[CONF_CLOUD_PROJECT_ID] = cloud_project_id
- self._eligible_topics = eligible_topics
- return await self.async_step_pubsub_topic()
+ self._data[CONF_TOPIC_NAME] = topic_name
+ return await self.async_step_pubsub_topic_confirm()
+ device_access_project_id = self._data[CONF_PROJECT_ID]
+ try:
+ eligible_topics = await self._admin_client.list_eligible_topics(
+ device_access_project_id=device_access_project_id
+ )
+ except ApiException as err:
+ _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err)
+ return self.async_abort(reason="pubsub_api_error")
+ topics = [
+ *eligible_topics.topic_names, # Untranslated topic paths
+ CREATE_NEW_TOPIC_KEY,
+ ]
return self.async_show_form(
- step_id="pubsub",
+ step_id="pubsub_topic",
data_schema=vol.Schema(
{
- vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str,
+ vol.Required(
+ CONF_TOPIC_NAME, default=next(iter(topics))
+ ): SelectSelector(
+ SelectSelectorConfig(
+ translation_key="topic_name",
+ mode=SelectSelectorMode.LIST,
+ options=topics,
+ )
+ )
}
),
description_placeholders={
- "url": CLOUD_CONSOLE_URL,
"device_access_console_url": DEVICE_ACCESS_CONSOLE_URL,
"more_info_url": MORE_INFO_URL,
},
errors=errors,
)
- async def async_step_pubsub_topic(
- self, user_input: dict[str, Any] | None = None
+ async def async_step_pubsub_topic_confirm(
+ self, user_input: dict | None = None
) -> ConfigFlowResult:
- """Configure and create Pub/Sub topic."""
- if TYPE_CHECKING:
- assert self._eligible_topics
+ """Have the user confirm the Pub/Sub topic is set correctly in Device Access Console."""
if user_input is not None:
- self._data.update(user_input)
return await self.async_step_pubsub_subscription()
- topics = list(self._eligible_topics.topic_names)
return self.async_show_form(
- step_id="pubsub_topic",
- data_schema=vol.Schema(
- {
- vol.Optional(CONF_TOPIC_NAME, default=topics[0]): vol.In(topics),
- }
- ),
+ step_id="pubsub_topic_confirm",
description_placeholders={
- "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL,
+ "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format(
+ project_id=self._data[CONF_PROJECT_ID]
+ ),
+ "topic_name": self._data[CONF_TOPIC_NAME],
"more_info_url": MORE_INFO_URL,
},
)
@@ -362,7 +389,7 @@ class NestFlowHandler(
)
return await self._async_finish()
- subscriptions = {}
+ subscriptions = []
try:
eligible_subscriptions = (
await self._admin_client.list_eligible_subscriptions(
@@ -375,10 +402,8 @@ class NestFlowHandler(
)
errors["base"] = "pubsub_api_error"
else:
- subscriptions.update(
- {name: name for name in eligible_subscriptions.subscription_names}
- )
- subscriptions[CREATE_NEW_SUBSCRIPTION_KEY] = "Create New"
+ subscriptions.extend(eligible_subscriptions.subscription_names)
+ subscriptions.append(CREATE_NEW_SUBSCRIPTION_KEY)
return self.async_show_form(
step_id="pubsub_subscription",
data_schema=vol.Schema(
@@ -386,7 +411,13 @@ class NestFlowHandler(
vol.Optional(
CONF_SUBSCRIPTION_NAME,
default=next(iter(subscriptions)),
- ): vol.In(subscriptions),
+ ): SelectSelector(
+ SelectSelectorConfig(
+ translation_key="subscription_name",
+ mode=SelectSelectorMode.LIST,
+ options=subscriptions,
+ )
+ )
}
),
description_placeholders={
diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json
index 07c34c51568..cd961276082 100644
--- a/homeassistant/components/nest/manifest.json
+++ b/homeassistant/components/nest/manifest.json
@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
- "requirements": ["google-nest-sdm==6.1.5"]
+ "requirements": ["google-nest-sdm==7.1.1"]
}
diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json
index a31a2856544..23da524ab7e 100644
--- a/homeassistant/components/nest/strings.json
+++ b/homeassistant/components/nest/strings.json
@@ -17,7 +17,7 @@
},
"device_project": {
"title": "Nest: Create a Device Access Project",
- "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).",
+ "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).",
"data": {
"project_id": "Device Access Project ID"
}
@@ -25,20 +25,18 @@
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
- "pubsub": {
- "title": "Configure Google Cloud Pub/Sub",
- "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).",
- "data": {
- "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]"
- }
- },
"pubsub_topic": {
"title": "Configure Cloud Pub/Sub topic",
- "description": "Nest devices publish updates on a Cloud Pub/Sub topic. Select the Pub/Sub topic below that is the same as the [Device Access Console]({device_access_console_url}). See the integration documentation for [more info]({more_info_url}).",
+ "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).",
"data": {
"topic_name": "Pub/Sub topic Name"
}
},
+ "pubsub_topic_confirm": {
+ "title": "Enable events",
+ "description": "The Nest Device Access Console needs to be configured to publish device events to your Pub/Sub topic.\n\n1. Visit the [Device Access Console]({device_access_console_url}).\n2. Open the project.\n3. Enable *Events* and set the Pub/Sub topic name to `{topic_name}`\n4. Click *Add & Validate* to verify the topic is configured correctly.\n\nSee the integration documentation for [more info]({more_info_url}).",
+ "submit": "Confirm"
+ },
"pubsub_subscription": {
"title": "Configure Cloud Pub/Sub subscription",
"description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).",
@@ -70,7 +68,8 @@
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
- "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "pubsub_api_error": "[%key:component::nest::config::error::pubsub_api_error%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -109,5 +108,17 @@
}
}
}
+ },
+ "selector": {
+ "topic_name": {
+ "options": {
+ "create_new_topic": "Create new topic"
+ }
+ },
+ "subscription_name": {
+ "options": {
+ "create_new_subscription": "Create new subscription"
+ }
+ }
}
}
diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py
index 6f14c9c76bb..9c92724c543 100644
--- a/homeassistant/components/netatmo/__init__.py
+++ b/homeassistant/components/netatmo/__init__.py
@@ -257,7 +257,6 @@ async def async_remove_config_entry_device(
return not any(
identifier
for identifier in device_entry.identifiers
- if identifier[0] == DOMAIN
- and identifier[1] in modules
+ if (identifier[0] == DOMAIN and identifier[1] in modules)
or identifier[1] in rooms
)
diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py
new file mode 100644
index 00000000000..7b2899c84aa
--- /dev/null
+++ b/homeassistant/components/netatmo/button.py
@@ -0,0 +1,73 @@
+"""Support for Netatmo/Bubendorff button."""
+
+from __future__ import annotations
+
+import logging
+
+from pyatmo import modules as NaModules
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON
+from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
+from .entity import NetatmoModuleEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Netatmo button platform."""
+
+ @callback
+ def _create_entity(netatmo_device: NetatmoDevice) -> None:
+ entity = NetatmoCoverPreferredPositionButton(netatmo_device)
+ _LOGGER.debug("Adding button %s", entity)
+ async_add_entities([entity])
+
+ entry.async_on_unload(
+ async_dispatcher_connect(hass, NETATMO_CREATE_BUTTON, _create_entity)
+ )
+
+
+class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity):
+ """Representation of a Netatmo cover preferred position button device."""
+
+ _attr_configuration_url = CONF_URL_CONTROL
+ _attr_entity_registry_enabled_default = False
+ _attr_translation_key = "preferred_position"
+ device: NaModules.Shutter
+
+ def __init__(self, netatmo_device: NetatmoDevice) -> None:
+ """Initialize the Netatmo device."""
+ super().__init__(netatmo_device)
+
+ self._publishers.extend(
+ [
+ {
+ "name": HOME,
+ "home_id": self.home.entity_id,
+ SIGNAL_NAME: f"{HOME}-{self.home.entity_id}",
+ },
+ ]
+ )
+ self._attr_unique_id = (
+ f"{self.device.entity_id}-{self.device_type}-preferred_position"
+ )
+
+ @callback
+ def async_update_callback(self) -> None:
+ """Update the entity's state."""
+ # No state to update for button
+
+ async def async_press(self) -> None:
+ """Handle button press to move the cover to a preferred position."""
+ _LOGGER.debug("Moving %s to a preferred position", self.device.entity_id)
+ await self.device.async_move_to_preferred_position()
diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py
index 74f2ebc84b2..d69a62f37f9 100644
--- a/homeassistant/components/netatmo/const.py
+++ b/homeassistant/components/netatmo/const.py
@@ -10,6 +10,7 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}"
PLATFORMS = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.CAMERA,
Platform.CLIMATE,
Platform.COVER,
@@ -45,6 +46,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera"
NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light"
NETATMO_CREATE_CLIMATE = "netatmo_create_climate"
NETATMO_CREATE_COVER = "netatmo_create_cover"
+NETATMO_CREATE_BUTTON = "netatmo_create_button"
NETATMO_CREATE_FAN = "netatmo_create_fan"
NETATMO_CREATE_LIGHT = "netatmo_create_light"
NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor"
diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py
index 3a28c3b8336..283ccc3740e 100644
--- a/homeassistant/components/netatmo/data_handler.py
+++ b/homeassistant/components/netatmo/data_handler.py
@@ -33,6 +33,7 @@ from .const import (
DOMAIN,
MANUFACTURER,
NETATMO_CREATE_BATTERY,
+ NETATMO_CREATE_BUTTON,
NETATMO_CREATE_CAMERA,
NETATMO_CREATE_CAMERA_LIGHT,
NETATMO_CREATE_CLIMATE,
@@ -350,7 +351,10 @@ class NetatmoDataHandler:
NETATMO_CREATE_CAMERA_LIGHT,
],
NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT],
- NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER],
+ NetatmoDeviceCategory.shutter: [
+ NETATMO_CREATE_COVER,
+ NETATMO_CREATE_BUTTON,
+ ],
NetatmoDeviceCategory.switch: [
NETATMO_CREATE_LIGHT,
NETATMO_CREATE_SWITCH,
diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py
index 71a8c548622..9f3fe7174ff 100644
--- a/homeassistant/components/netatmo/fan.py
+++ b/homeassistant/components/netatmo/fan.py
@@ -35,7 +35,7 @@ async def async_setup_entry(
@callback
def _create_entity(netatmo_device: NetatmoDevice) -> None:
entity = NetatmoFan(netatmo_device)
- _LOGGER.debug("Adding cover %s", entity)
+ _LOGGER.debug("Adding fan %s", entity)
async_add_entities([entity])
entry.async_on_unload(
diff --git a/homeassistant/components/netatmo/icons.json b/homeassistant/components/netatmo/icons.json
index 9f712e08f33..099c6aa1784 100644
--- a/homeassistant/components/netatmo/icons.json
+++ b/homeassistant/components/netatmo/icons.json
@@ -13,6 +13,11 @@
}
}
},
+ "button": {
+ "preferred_position": {
+ "default": "mdi:window-shutter-auto"
+ }
+ },
"sensor": {
"temp_trend": {
"default": "mdi:trending-up"
diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json
index 6b91aa204b2..23b800e460d 100644
--- a/homeassistant/components/netatmo/strings.json
+++ b/homeassistant/components/netatmo/strings.json
@@ -181,6 +181,11 @@
}
}
},
+ "button": {
+ "preferred_position": {
+ "name": "Preferred position"
+ }
+ },
"sensor": {
"temp_trend": {
"name": "Temperature trend"
diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py
index f33349c56ce..4346cbe8689 100644
--- a/homeassistant/components/netdata/sensor.py
+++ b/homeassistant/components/netdata/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py
index 965e3618645..a0a5b76eee5 100644
--- a/homeassistant/components/netgear/config_flow.py
+++ b/homeassistant/components/netgear/config_flow.py
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -24,6 +23,12 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from homeassistant.util.network import is_ipv4_address
from .const import (
@@ -129,7 +134,7 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Initialize flow from ssdp."""
updated_data: dict[str, str | int | bool] = {}
@@ -144,10 +149,10 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info)
- if ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp:
+ if ATTR_UPNP_SERIAL not in discovery_info.upnp:
return self.async_abort(reason="no_serial")
- await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL])
+ await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_SERIAL])
self._abort_if_unique_id_configured(updates=updated_data)
if device_url.scheme == "https":
@@ -157,18 +162,14 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN):
updated_data[CONF_PORT] = DEFAULT_PORT
for model in MODELS_PORT_80:
- if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith(
+ if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith(
model
- ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith(
- model
- ):
+ ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model):
updated_data[CONF_PORT] = PORT_80
for model in MODELS_PORT_5555:
- if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith(
+ if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith(
model
- ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith(
- model
- ):
+ ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model):
updated_data[CONF_PORT] = PORT_5555
updated_data[CONF_SSL] = True
diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py
index 40394677362..f7a683326d3 100644
--- a/homeassistant/components/netgear/const.py
+++ b/homeassistant/components/netgear/const.py
@@ -37,6 +37,7 @@ MODELS_PORT_80 = [
"RBR",
"RBS",
"RBW",
+ "RS",
"LBK",
"LBR",
"CBK",
diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py
index 1e4bf2480e9..d81f556193b 100644
--- a/homeassistant/components/netgear/router.py
+++ b/homeassistant/components/netgear/router.py
@@ -210,6 +210,12 @@ class NetgearRouter:
for device in self.devices.values():
device["active"] = now - device["last_seen"] <= self._consider_home
+ if not device["active"]:
+ device["link_rate"] = None
+ device["signal"] = None
+ device["ip"] = None
+ device["ssid"] = None
+ device["conn_ap_mac"] = None
if new_device:
_LOGGER.debug("Netgear tracker: new device found")
diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py
index 72087dd28db..d807f7aed0a 100644
--- a/homeassistant/components/netgear/sensor.py
+++ b/homeassistant/components/netgear/sensor.py
@@ -47,18 +47,21 @@ SENSOR_TYPES = {
key="type",
translation_key="link_type",
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
),
"link_rate": SensorEntityDescription(
key="link_rate",
translation_key="link_rate",
native_unit_of_measurement="Mbps",
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
),
"signal": SensorEntityDescription(
key="signal",
translation_key="signal_strength",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
),
"ssid": SensorEntityDescription(
key="ssid",
@@ -69,6 +72,7 @@ SENSOR_TYPES = {
key="conn_ap_mac",
translation_key="access_point_mac",
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
),
}
@@ -326,8 +330,6 @@ async def async_setup_entry(
class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity):
"""Representation of a device connected to a Netgear router."""
- _attr_entity_registry_enabled_default = False
-
def __init__(
self,
coordinator: DataUpdateCoordinator,
@@ -342,6 +344,11 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity):
self._attr_unique_id = f"{self._mac}-{attribute}"
self._state = device.get(attribute)
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return super().available and self._device.get(self._attribute) is not None
+
@property
def native_value(self):
"""Return the state of the sensor."""
diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json
index 9f3b1aeec9e..4d4e72c156f 100644
--- a/homeassistant/components/netgear/strings.json
+++ b/homeassistant/components/netgear/strings.json
@@ -19,8 +19,8 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "not_ipv4_address": "No IPv4 address in ssdp discovery information",
- "no_serial": "No serial number in ssdp discovery information"
+ "not_ipv4_address": "No IPv4 address in SSDP discovery information",
+ "no_serial": "No serial number in SSDP discovery information"
}
},
"options": {
@@ -48,7 +48,7 @@
"name": "SSID"
},
"access_point_mac": {
- "name": "Access point mac"
+ "name": "Access point MAC"
},
"upload_today": {
"name": "Upload today"
diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py
index 5c2b93bcae7..4560b7a2ecc 100644
--- a/homeassistant/components/netio/switch.py
+++ b/homeassistant/components/netio/switch.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py
index 6c5b6f80eda..120ae9dfd7c 100644
--- a/homeassistant/components/network/const.py
+++ b/homeassistant/components/network/const.py
@@ -6,7 +6,7 @@ from typing import Final
import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
DOMAIN: Final = "network"
STORAGE_KEY: Final = "core.network"
diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json
new file mode 100644
index 00000000000..6aca7343221
--- /dev/null
+++ b/homeassistant/components/network/strings.json
@@ -0,0 +1,10 @@
+{
+ "system_health": {
+ "info": {
+ "adapters": "Adapters",
+ "ipv4_addresses": "IPv4 addresses",
+ "ipv6_addresses": "IPv6 addresses",
+ "announce_addresses": "Announce addresses"
+ }
+ }
+}
diff --git a/homeassistant/components/network/system_health.py b/homeassistant/components/network/system_health.py
new file mode 100644
index 00000000000..ebabe055539
--- /dev/null
+++ b/homeassistant/components/network/system_health.py
@@ -0,0 +1,53 @@
+"""Provide info to system health."""
+
+from typing import Any
+
+from homeassistant.components import system_health
+from homeassistant.core import HomeAssistant, callback
+
+from . import Adapter, async_get_adapters, async_get_announce_addresses
+from .models import IPv4ConfiguredAddress, IPv6ConfiguredAddress
+
+
+@callback
+def async_register(
+ hass: HomeAssistant, register: system_health.SystemHealthRegistration
+) -> None:
+ """Register system health callbacks."""
+ register.async_register_info(system_health_info, "/config/network")
+
+
+def _format_ips(ips: list[IPv4ConfiguredAddress] | list[IPv6ConfiguredAddress]) -> str:
+ return ", ".join([f"{ip['address']}/{ip['network_prefix']!s}" for ip in ips])
+
+
+def _get_adapter_info(adapter: Adapter) -> str:
+ state = "enabled" if adapter["enabled"] else "disabled"
+ default = ", default" if adapter["default"] else ""
+ auto = ", auto" if adapter["auto"] else ""
+ return f"{adapter['name']} ({state}{default}{auto})"
+
+
+async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
+ """Get info for the info page."""
+
+ adapters = await async_get_adapters(hass)
+ data: dict[str, Any] = {
+ # k: v for adapter in adapters for k, v in _get_adapter_info(adapter).items()
+ "adapters": ", ".join([_get_adapter_info(adapter) for adapter in adapters]),
+ "ipv4_addresses": ", ".join(
+ [
+ f"{adapter['name']} ({_format_ips(adapter['ipv4'])})"
+ for adapter in adapters
+ ]
+ ),
+ "ipv6_addresses": ", ".join(
+ [
+ f"{adapter['name']} ({_format_ips(adapter['ipv6'])})"
+ for adapter in adapters
+ ]
+ ),
+ "announce_addresses": ", ".join(await async_get_announce_addresses(hass)),
+ }
+
+ return data
diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py
index 5c6482da59a..7a7ceff338e 100644
--- a/homeassistant/components/neurio_energy/sensor.py
+++ b/homeassistant/components/neurio_energy/sensor.py
@@ -17,11 +17,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py
index becd664756b..81e7800fd01 100644
--- a/homeassistant/components/nexia/climate.py
+++ b/homeassistant/components/nexia/climate.py
@@ -32,8 +32,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import VolDictType
diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py
index a487a3f1414..3edff53919d 100644
--- a/homeassistant/components/nextcloud/__init__.py
+++ b/homeassistant/components/nextcloud/__init__.py
@@ -9,7 +9,6 @@ from nextcloudmonitor import (
NextcloudMonitorRequestError,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_URL,
@@ -21,15 +20,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
-from .coordinator import NextcloudDataUpdateCoordinator
+from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator
PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE)
_LOGGER = logging.getLogger(__name__)
-type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator]
-
async def async_setup_entry(hass: HomeAssistant, entry: NextcloudConfigEntry) -> bool:
"""Set up the Nextcloud integration."""
diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py
index c9d19efbd45..10e1a000a68 100644
--- a/homeassistant/components/nextcloud/binary_sensor.py
+++ b/homeassistant/components/nextcloud/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import NextcloudConfigEntry
+from .coordinator import NextcloudConfigEntry
from .entity import NextcloudEntity
BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [
diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py
index b5dc5e29507..d6bccec07bb 100644
--- a/homeassistant/components/nextcloud/coordinator.py
+++ b/homeassistant/components/nextcloud/coordinator.py
@@ -14,12 +14,16 @@ from .const import DEFAULT_SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
+type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator]
+
class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Nextcloud data update coordinator."""
+ config_entry: NextcloudConfigEntry
+
def __init__(
- self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry
+ self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: NextcloudConfigEntry
) -> None:
"""Initialize the Nextcloud coordinator."""
self.ncm = ncm
@@ -28,6 +32,7 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=self.url,
update_interval=DEFAULT_SCAN_INTERVAL,
)
diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py
index 6632b2674eb..f2ebba7fdb2 100644
--- a/homeassistant/components/nextcloud/entity.py
+++ b/homeassistant/components/nextcloud/entity.py
@@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import NextcloudConfigEntry
from .const import DOMAIN
-from .coordinator import NextcloudDataUpdateCoordinator
+from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator
class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]):
diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py
index 19ac7bb0df7..a6722821012 100644
--- a/homeassistant/components/nextcloud/sensor.py
+++ b/homeassistant/components/nextcloud/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utc_from_timestamp
-from . import NextcloudConfigEntry
+from .coordinator import NextcloudConfigEntry
from .entity import NextcloudEntity
UNIT_OF_LOAD: Final[str] = "load"
diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py
index 5b9de52ad1d..aad6412b7b3 100644
--- a/homeassistant/components/nextcloud/update.py
+++ b/homeassistant/components/nextcloud/update.py
@@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import NextcloudConfigEntry
+from .coordinator import NextcloudConfigEntry
from .entity import NextcloudEntity
diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py
index ae7a4e615d4..50674a7ed46 100644
--- a/homeassistant/components/nfandroidtv/__init__.py
+++ b/homeassistant/components/nfandroidtv/__init__.py
@@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DATA_HASS_CONFIG, DOMAIN
diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py
index dd6b15400d9..f6d9bcde432 100644
--- a/homeassistant/components/nfandroidtv/notify.py
+++ b/homeassistant/components/nfandroidtv/notify.py
@@ -20,7 +20,7 @@ from homeassistant.components.notify import (
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py
index ed6d18f7888..faaac5f165a 100644
--- a/homeassistant/components/nibe_heatpump/coordinator.py
+++ b/homeassistant/components/nibe_heatpump/coordinator.py
@@ -12,7 +12,7 @@ from nibe.coil import Coil, CoilData
from nibe.connection import Connection
from nibe.exceptions import CoilNotFoundException, ReadException
from nibe.heatpump import HeatPump, Series
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py
index da3940117e9..291d4221d6c 100644
--- a/homeassistant/components/nice_go/config_flow.py
+++ b/homeassistant/components/nice_go/config_flow.py
@@ -50,7 +50,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN):
)
except AuthFailedError:
errors["base"] = "invalid_auth"
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
@@ -92,7 +92,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN):
)
except AuthFailedError:
errors["base"] = "invalid_auth"
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json
index 1af23ec4d9b..8f43ed8a3e8 100644
--- a/homeassistant/components/nice_go/manifest.json
+++ b/homeassistant/components/nice_go/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["nice_go"],
- "requirements": ["nice-go==1.0.0"]
+ "requirements": ["nice-go==1.0.1"]
}
diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py
index 80f47e56438..5c2b372fd25 100644
--- a/homeassistant/components/niko_home_control/light.py
+++ b/homeassistant/components/niko_home_control/light.py
@@ -18,8 +18,7 @@ from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import issue_registry as ir
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json
index a75b0d72dca..57f83180eb0 100644
--- a/homeassistant/components/niko_home_control/manifest.json
+++ b/homeassistant/components/niko_home_control/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/niko_home_control",
"iot_class": "local_push",
"loggers": ["nikohomecontrol"],
- "requirements": ["nhc==0.3.4"]
+ "requirements": ["nhc==0.3.9"]
}
diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py
index 7600a878548..31259349dea 100644
--- a/homeassistant/components/nilu/air_quality.py
+++ b/homeassistant/components/nilu/air_quality.py
@@ -34,7 +34,7 @@ from homeassistant.const import (
CONF_SHOW_ON_MAP,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py
index a1ba9ae0c61..24c016e5e64 100644
--- a/homeassistant/components/nina/config_flow.py
+++ b/homeassistant/components/nina/config_flow.py
@@ -14,9 +14,8 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import callback
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import VolDictType
from .const import (
diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py
index 865ae33b38c..4f24cde0578 100644
--- a/homeassistant/components/nissan_leaf/__init__.py
+++ b/homeassistant/components/nissan_leaf/__init__.py
@@ -18,7 +18,7 @@ import voluptuous as vol
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_point_in_utc_time
diff --git a/homeassistant/components/nissan_leaf/strings.json b/homeassistant/components/nissan_leaf/strings.json
index d733e39a0fc..78335ab4c14 100644
--- a/homeassistant/components/nissan_leaf/strings.json
+++ b/homeassistant/components/nissan_leaf/strings.json
@@ -2,17 +2,17 @@
"services": {
"start_charge": {
"name": "Start charge",
- "description": "Starts the vehicle charging. It must be plugged in first!\n.",
+ "description": "Starts the vehicle charging. It must be plugged in first!",
"fields": {
"vin": {
"name": "VIN",
- "description": "The vehicle identification number (VIN) of the vehicle, 17 characters\n."
+ "description": "The vehicle identification number (VIN) of the vehicle, 17 characters."
}
}
},
"update": {
"name": "Update",
- "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.\n.",
+ "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.",
"fields": {
"vin": {
"name": "[%key:component::nissan_leaf::services::start_charge::fields::vin::name%]",
diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py
index dcb4e1361fd..72bf9284573 100644
--- a/homeassistant/components/nmap_tracker/__init__.py
+++ b/homeassistant/components/nmap_tracker/__init__.py
@@ -21,12 +21,11 @@ from homeassistant.components.device_tracker import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
CONF_HOME_INTERVAL,
diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py
index e05150995aa..1f436edd60c 100644
--- a/homeassistant/components/nmap_tracker/config_flow.py
+++ b/homeassistant/components/nmap_tracker/config_flow.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import VolDictType
from .const import (
diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json
index ebee6b116e6..06f94e0566f 100644
--- a/homeassistant/components/nmap_tracker/manifest.json
+++ b/homeassistant/components/nmap_tracker/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/nmap_tracker",
"iot_class": "local_polling",
"loggers": ["nmap"],
- "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.9"]
+ "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.9"]
}
diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json
index ef660c7e991..3cbbea007b1 100644
--- a/homeassistant/components/nmap_tracker/strings.json
+++ b/homeassistant/components/nmap_tracker/strings.json
@@ -21,7 +21,7 @@
"config": {
"step": {
"user": {
- "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32).",
+ "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP addresses (192.168.1.1), IP networks (192.168.0.0/24) or IP ranges (192.168.1.0-32).",
"data": {
"hosts": "Network addresses (comma separated) to scan",
"home_interval": "Minimum number of minutes between scans of active devices (preserve battery)",
@@ -31,7 +31,7 @@
}
},
"error": {
- "invalid_hosts": "Invalid Hosts"
+ "invalid_hosts": "Invalid hosts"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py
index 11013d471b5..7d06baf37b6 100644
--- a/homeassistant/components/nmbs/__init__.py
+++ b/homeassistant/components/nmbs/__init__.py
@@ -1 +1,45 @@
-"""The nmbs component."""
+"""The NMBS component."""
+
+import logging
+
+from pyrail import iRail
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORMS = [Platform.SENSOR]
+
+
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the NMBS component."""
+
+ api_client = iRail()
+
+ hass.data.setdefault(DOMAIN, {})
+ station_response = await hass.async_add_executor_job(api_client.get_stations)
+ if station_response == -1:
+ return False
+ hass.data[DOMAIN] = station_response["station"]
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up NMBS from a config entry."""
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py
new file mode 100644
index 00000000000..e45b2d9adeb
--- /dev/null
+++ b/homeassistant/components/nmbs/config_flow.py
@@ -0,0 +1,182 @@
+"""Config flow for NMBS integration."""
+
+from typing import Any
+
+from pyrail import iRail
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import Platform
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.selector import (
+ BooleanSelector,
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import (
+ CONF_EXCLUDE_VIAS,
+ CONF_SHOW_ON_MAP,
+ CONF_STATION_FROM,
+ CONF_STATION_LIVE,
+ CONF_STATION_TO,
+ DOMAIN,
+)
+
+
+class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
+ """NMBS config flow."""
+
+ def __init__(self) -> None:
+ """Initialize."""
+ self.api_client = iRail()
+ self.stations: list[dict[str, Any]] = []
+
+ async def _fetch_stations(self) -> list[dict[str, Any]]:
+ """Fetch the stations."""
+ stations_response = await self.hass.async_add_executor_job(
+ self.api_client.get_stations
+ )
+ if stations_response == -1:
+ raise CannotConnect("The API is currently unavailable.")
+ return stations_response["station"]
+
+ async def _fetch_stations_choices(self) -> list[SelectOptionDict]:
+ """Fetch the stations options."""
+
+ if len(self.stations) == 0:
+ self.stations = await self._fetch_stations()
+
+ return [
+ SelectOptionDict(value=station["id"], label=station["standardname"])
+ for station in self.stations
+ ]
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the step to setup a connection between 2 stations."""
+
+ try:
+ choices = await self._fetch_stations_choices()
+ except CannotConnect:
+ return self.async_abort(reason="api_unavailable")
+
+ errors: dict = {}
+ if user_input is not None:
+ if user_input[CONF_STATION_FROM] == user_input[CONF_STATION_TO]:
+ errors["base"] = "same_station"
+ else:
+ [station_from] = [
+ station
+ for station in self.stations
+ if station["id"] == user_input[CONF_STATION_FROM]
+ ]
+ [station_to] = [
+ station
+ for station in self.stations
+ if station["id"] == user_input[CONF_STATION_TO]
+ ]
+ vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else ""
+ await self.async_set_unique_id(
+ f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}{vias}"
+ )
+ self._abort_if_unique_id_configured()
+
+ config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}"
+ return self.async_create_entry(
+ title=config_entry_name,
+ data=user_input,
+ )
+
+ schema = vol.Schema(
+ {
+ vol.Required(CONF_STATION_FROM): SelectSelector(
+ SelectSelectorConfig(
+ options=choices,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ vol.Required(CONF_STATION_TO): SelectSelector(
+ SelectSelectorConfig(
+ options=choices,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ vol.Optional(CONF_EXCLUDE_VIAS): BooleanSelector(),
+ vol.Optional(CONF_SHOW_ON_MAP): BooleanSelector(),
+ },
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=schema,
+ errors=errors,
+ )
+
+ async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
+ """Import configuration from yaml."""
+ try:
+ self.stations = await self._fetch_stations()
+ except CannotConnect:
+ return self.async_abort(reason="api_unavailable")
+
+ station_from = None
+ station_to = None
+ station_live = None
+ for station in self.stations:
+ if user_input[CONF_STATION_FROM] in (
+ station["standardname"],
+ station["name"],
+ ):
+ station_from = station
+ if user_input[CONF_STATION_TO] in (
+ station["standardname"],
+ station["name"],
+ ):
+ station_to = station
+ if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in (
+ station["standardname"],
+ station["name"],
+ ):
+ station_live = station
+
+ if station_from is None or station_to is None:
+ return self.async_abort(reason="invalid_station")
+ if station_from == station_to:
+ return self.async_abort(reason="same_station")
+
+ # config flow uses id and not the standard name
+ user_input[CONF_STATION_FROM] = station_from["id"]
+ user_input[CONF_STATION_TO] = station_to["id"]
+
+ if station_live:
+ user_input[CONF_STATION_LIVE] = station_live["id"]
+ entity_registry = er.async_get(self.hass)
+ prefix = "live"
+ vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else ""
+ if entity_id := entity_registry.async_get_entity_id(
+ Platform.SENSOR,
+ DOMAIN,
+ f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}",
+ ):
+ new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}"
+ entity_registry.async_update_entity(
+ entity_id, new_unique_id=new_unique_id
+ )
+ if entity_id := entity_registry.async_get_entity_id(
+ Platform.SENSOR,
+ DOMAIN,
+ f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}",
+ ):
+ new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}"
+ entity_registry.async_update_entity(
+ entity_id, new_unique_id=new_unique_id
+ )
+
+ return await self.async_step_user(user_input)
+
+
+class CannotConnect(Exception):
+ """Error to indicate we cannot connect to NMBS."""
diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py
new file mode 100644
index 00000000000..fddb7365501
--- /dev/null
+++ b/homeassistant/components/nmbs/const.py
@@ -0,0 +1,36 @@
+"""The NMBS integration."""
+
+from typing import Final
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+DOMAIN: Final = "nmbs"
+
+PLATFORMS: Final = [Platform.SENSOR]
+
+CONF_STATION_FROM = "station_from"
+CONF_STATION_TO = "station_to"
+CONF_STATION_LIVE = "station_live"
+CONF_EXCLUDE_VIAS = "exclude_vias"
+CONF_SHOW_ON_MAP = "show_on_map"
+
+
+def find_station_by_name(hass: HomeAssistant, station_name: str):
+ """Find given station_name in the station list."""
+ return next(
+ (
+ s
+ for s in hass.data[DOMAIN]
+ if station_name in (s["standardname"], s["name"])
+ ),
+ None,
+ )
+
+
+def find_station(hass: HomeAssistant, station_name: str):
+ """Find given station_id in the station list."""
+ return next(
+ (s for s in hass.data[DOMAIN] if station_name in s["id"]),
+ None,
+ )
diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json
index e17d1227bed..9016eff11f8 100644
--- a/homeassistant/components/nmbs/manifest.json
+++ b/homeassistant/components/nmbs/manifest.json
@@ -1,7 +1,8 @@
{
"domain": "nmbs",
"name": "NMBS",
- "codeowners": ["@thibmaek"],
+ "codeowners": [],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nmbs",
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py
index 6ccdc742430..6d13777e10a 100644
--- a/homeassistant/components/nmbs/sensor.py
+++ b/homeassistant/components/nmbs/sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
from pyrail import iRail
import voluptuous as vol
@@ -11,18 +12,32 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_NAME,
+ CONF_PLATFORM,
CONF_SHOW_ON_MAP,
UnitOfTime,
)
-from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
+
+from .const import ( # noqa: F401
+ CONF_EXCLUDE_VIAS,
+ CONF_STATION_FROM,
+ CONF_STATION_LIVE,
+ CONF_STATION_TO,
+ DOMAIN,
+ PLATFORMS,
+ find_station,
+ find_station_by_name,
+)
_LOGGER = logging.getLogger(__name__)
@@ -33,11 +48,6 @@ DEFAULT_NAME = "NMBS"
DEFAULT_ICON = "mdi:train"
DEFAULT_ICON_ALERT = "mdi:alert-octagon"
-CONF_STATION_FROM = "station_from"
-CONF_STATION_TO = "station_to"
-CONF_STATION_LIVE = "station_live"
-CONF_EXCLUDE_VIAS = "exclude_vias"
-
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_STATION_FROM): cv.string,
@@ -73,33 +83,99 @@ def get_ride_duration(departure_time, arrival_time, delay=0):
return duration_time + get_delay_in_minutes(delay)
-def setup_platform(
+async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
- add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the NMBS sensor with iRail API."""
- api_client = iRail()
+ if config[CONF_PLATFORM] == DOMAIN:
+ if CONF_SHOW_ON_MAP not in config:
+ config[CONF_SHOW_ON_MAP] = False
+ if CONF_EXCLUDE_VIAS not in config:
+ config[CONF_EXCLUDE_VIAS] = False
- name = config[CONF_NAME]
- show_on_map = config[CONF_SHOW_ON_MAP]
- station_from = config[CONF_STATION_FROM]
- station_to = config[CONF_STATION_TO]
- station_live = config.get(CONF_STATION_LIVE)
- excl_vias = config[CONF_EXCLUDE_VIAS]
+ station_types = [CONF_STATION_FROM, CONF_STATION_TO, CONF_STATION_LIVE]
- sensors: list[SensorEntity] = [
- NMBSSensor(api_client, name, show_on_map, station_from, station_to, excl_vias)
- ]
+ for station_type in station_types:
+ station = (
+ find_station_by_name(hass, config[station_type])
+ if station_type in config
+ else None
+ )
+ if station is None and station_type in config:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_import_issue_station_not_found",
+ breaks_in_ha_version="2025.7.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml_import_issue_station_not_found",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "NMBS",
+ "station_name": config[station_type],
+ "url": "/config/integrations/dashboard/add?domain=nmbs",
+ },
+ )
+ return
- if station_live is not None:
- sensors.append(
- NMBSLiveBoard(api_client, station_live, station_from, station_to)
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=config,
+ )
)
- add_entities(sensors, True)
+ async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ breaks_in_ha_version="2025.7.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "NMBS",
+ },
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up NMBS sensor entities based on a config entry."""
+ api_client = iRail()
+
+ name = config_entry.data.get(CONF_NAME, None)
+ show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False)
+ excl_vias = config_entry.data.get(CONF_EXCLUDE_VIAS, False)
+
+ station_from = find_station(hass, config_entry.data[CONF_STATION_FROM])
+ station_to = find_station(hass, config_entry.data[CONF_STATION_TO])
+
+ # setup the connection from station to station
+ # setup a disabled liveboard for both from and to station
+ async_add_entities(
+ [
+ NMBSSensor(
+ api_client, name, show_on_map, station_from, station_to, excl_vias
+ ),
+ NMBSLiveBoard(
+ api_client, station_from, station_from, station_to, excl_vias
+ ),
+ NMBSLiveBoard(api_client, station_to, station_from, station_to, excl_vias),
+ ]
+ )
class NMBSLiveBoard(SensorEntity):
@@ -107,29 +183,43 @@ class NMBSLiveBoard(SensorEntity):
_attr_attribution = "https://api.irail.be/"
- def __init__(self, api_client, live_station, station_from, station_to):
+ def __init__(
+ self,
+ api_client: iRail,
+ live_station: dict[str, Any],
+ station_from: dict[str, Any],
+ station_to: dict[str, Any],
+ excl_vias: bool,
+ ) -> None:
"""Initialize the sensor for getting liveboard data."""
self._station = live_station
self._api_client = api_client
self._station_from = station_from
self._station_to = station_to
- self._attrs = {}
- self._state = None
+
+ self._excl_vias = excl_vias
+ self._attrs: dict[str, Any] | None = {}
+ self._state: str | None = None
+
+ self.entity_registry_enabled_default = False
@property
- def name(self):
+ def name(self) -> str:
"""Return the sensor default name."""
- return f"NMBS Live ({self._station})"
+ return f"Trains in {self._station['standardname']}"
@property
- def unique_id(self):
- """Return a unique ID."""
- unique_id = f"{self._station}_{self._station_from}_{self._station_to}"
+ def unique_id(self) -> str:
+ """Return the unique ID."""
- return f"nmbs_live_{unique_id}"
+ unique_id = (
+ f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}"
+ )
+ vias = "_excl_vias" if self._excl_vias else ""
+ return f"nmbs_live_{unique_id}{vias}"
@property
- def icon(self):
+ def icon(self) -> str:
"""Return the default icon or an alert icon if delays."""
if self._attrs and int(self._attrs["delay"]) > 0:
return DEFAULT_ICON_ALERT
@@ -137,12 +227,12 @@ class NMBSLiveBoard(SensorEntity):
return DEFAULT_ICON
@property
- def native_value(self):
+ def native_value(self) -> str | None:
"""Return sensor state."""
return self._state
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the sensor attributes if data is available."""
if self._state is None or not self._attrs:
return None
@@ -155,7 +245,7 @@ class NMBSLiveBoard(SensorEntity):
"departure_minutes": departure,
"extra_train": int(self._attrs["isExtra"]) > 0,
"vehicle_id": self._attrs["vehicle"],
- "monitored_station": self._station,
+ "monitored_station": self._station["standardname"],
}
if delay > 0:
@@ -166,7 +256,7 @@ class NMBSLiveBoard(SensorEntity):
def update(self) -> None:
"""Set the state equal to the next departure."""
- liveboard = self._api_client.get_liveboard(self._station)
+ liveboard = self._api_client.get_liveboard(self._station["id"])
if liveboard == API_FAILURE:
_LOGGER.warning("API failed in NMBSLiveBoard")
@@ -195,8 +285,14 @@ class NMBSSensor(SensorEntity):
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
def __init__(
- self, api_client, name, show_on_map, station_from, station_to, excl_vias
- ):
+ self,
+ api_client: iRail,
+ name: str,
+ show_on_map: bool,
+ station_from: dict[str, Any],
+ station_to: dict[str, Any],
+ excl_vias: bool,
+ ) -> None:
"""Initialize the NMBS connection sensor."""
self._name = name
self._show_on_map = show_on_map
@@ -205,16 +301,26 @@ class NMBSSensor(SensorEntity):
self._station_to = station_to
self._excl_vias = excl_vias
- self._attrs = {}
+ self._attrs: dict[str, Any] | None = {}
self._state = None
@property
- def name(self):
+ def unique_id(self) -> str:
+ """Return the unique ID."""
+ unique_id = f"{self._station_from['id']}_{self._station_to['id']}"
+
+ vias = "_excl_vias" if self._excl_vias else ""
+ return f"nmbs_connection_{unique_id}{vias}"
+
+ @property
+ def name(self) -> str:
"""Return the name of the sensor."""
+ if self._name is None:
+ return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}"
return self._name
@property
- def icon(self):
+ def icon(self) -> str:
"""Return the sensor default icon or an alert icon if any delay."""
if self._attrs:
delay = get_delay_in_minutes(self._attrs["departure"]["delay"])
@@ -224,7 +330,7 @@ class NMBSSensor(SensorEntity):
return "mdi:train"
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return sensor attributes if data is available."""
if self._state is None or not self._attrs:
return None
@@ -234,7 +340,7 @@ class NMBSSensor(SensorEntity):
canceled = int(self._attrs["departure"]["canceled"])
attrs = {
- "destination": self._station_to,
+ "destination": self._attrs["departure"]["station"],
"direction": self._attrs["departure"]["direction"]["name"],
"platform_arriving": self._attrs["arrival"]["platform"],
"platform_departing": self._attrs["departure"]["platform"],
@@ -271,12 +377,12 @@ class NMBSSensor(SensorEntity):
return attrs
@property
- def native_value(self):
+ def native_value(self) -> int | None:
"""Return the state of the device."""
return self._state
@property
- def station_coordinates(self):
+ def station_coordinates(self) -> list[float]:
"""Get the lat, long coordinates for station."""
if self._state is None or not self._attrs:
return []
@@ -286,7 +392,7 @@ class NMBSSensor(SensorEntity):
return [latitude, longitude]
@property
- def is_via_connection(self):
+ def is_via_connection(self) -> bool:
"""Return whether the connection goes through another station."""
if not self._attrs:
return False
@@ -296,7 +402,7 @@ class NMBSSensor(SensorEntity):
def update(self) -> None:
"""Set the state to the duration of a connection."""
connections = self._api_client.get_connections(
- self._station_from, self._station_to
+ self._station_from["id"], self._station_to["id"]
)
if connections == API_FAILURE:
diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json
new file mode 100644
index 00000000000..3e7aa8d05bd
--- /dev/null
+++ b/homeassistant/components/nmbs/strings.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
+ "api_unavailable": "The API is currently unavailable.",
+ "same_station": "[%key:component::nmbs::config::error::same_station%]",
+ "invalid_station": "Invalid station."
+ },
+ "error": {
+ "same_station": "Departure and arrival station can not be the same."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "station_from": "Departure station",
+ "station_to": "Arrival station",
+ "exclude_vias": "Direct connections only",
+ "show_on_map": "Display on map"
+ },
+ "data_description": {
+ "station_from": "Station where the train departs",
+ "station_to": "Station where the train arrives",
+ "exclude_vias": "Exclude connections with transfers",
+ "show_on_map": "Show the station on the map"
+ }
+ }
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_station_not_found": {
+ "title": "The {integration_title} YAML configuration import failed",
+ "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
+ }
+ }
+}
diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py
index cb02490ac08..c23177ddf94 100644
--- a/homeassistant/components/no_ip/__init__.py
+++ b/homeassistant/components/no_ip/__init__.py
@@ -11,11 +11,11 @@ import voluptuous as vol
from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py
index b165478927e..f6ec9dc4bf2 100644
--- a/homeassistant/components/noaa_tides/sensor.py
+++ b/homeassistant/components/noaa_tides/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.unit_system import METRIC_SYSTEM
diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py
index 5b777205c8d..3bbf46f0264 100644
--- a/homeassistant/components/nobo_hub/__init__.py
+++ b/homeassistant/components/nobo_hub/__init__.py
@@ -7,7 +7,7 @@ from pynobo import nobo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN
diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py
index bba4737550b..36de8c8b1ad 100644
--- a/homeassistant/components/norway_air/air_quality.py
+++ b/homeassistant/components/norway_air/air_quality.py
@@ -14,8 +14,8 @@ from homeassistant.components.air_quality import (
)
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py
index 0b7a25ced3e..97759db4c13 100644
--- a/homeassistant/components/notify/__init__.py
+++ b/homeassistant/components/notify/__init__.py
@@ -8,14 +8,14 @@ from functools import partial
import logging
from typing import Any, final, override
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
-import homeassistant.components.persistent_notification as pn
+from homeassistant.components import persistent_notification as pn
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py
index 29064f24a66..11ce4e801a1 100644
--- a/homeassistant/components/notify/const.py
+++ b/homeassistant/components/notify/const.py
@@ -4,7 +4,7 @@ import logging
import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
ATTR_DATA = "data"
diff --git a/homeassistant/components/notify_events/__init__.py b/homeassistant/components/notify_events/__init__.py
index 2be97d709a9..76cfd9be4ff 100644
--- a/homeassistant/components/notify_events/__init__.py
+++ b/homeassistant/components/notify_events/__init__.py
@@ -4,8 +4,7 @@ import voluptuous as vol
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py
index f99790664da..7ae9b3a4d9f 100644
--- a/homeassistant/components/nsw_fuel_station/sensor.py
+++ b/homeassistant/components/nsw_fuel_station/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CURRENCY_CENT, UnitOfVolume
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py
index 4a9789c7e51..ac6771bb1bd 100644
--- a/homeassistant/components/nuki/config_flow.py
+++ b/homeassistant/components/nuki/config_flow.py
@@ -9,10 +9,10 @@ from pynuki.bridge import InvalidCredentialsException
from requests.exceptions import RequestException
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN
from .helpers import CannotConnect, InvalidAuth, parse_id
@@ -75,7 +75,7 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_validate(user_input)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a DHCP discovered Nuki bridge."""
await self.async_set_unique_id(discovery_info.hostname[12:].upper())
diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py
index 00122132d44..d3882bea290 100644
--- a/homeassistant/components/numato/__init__.py
+++ b/homeassistant/components/numato/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py
index 9f4aef08aa9..3e9d3448af2 100644
--- a/homeassistant/components/number/__init__.py
+++ b/homeassistant/components/number/__init__.py
@@ -10,7 +10,7 @@ import logging
from math import ceil, floor
from typing import TYPE_CHECKING, Any, Self, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -68,8 +68,8 @@ __all__ = [
"DEFAULT_MIN_VALUE",
"DEFAULT_STEP",
"DOMAIN",
- "PLATFORM_SCHEMA_BASE",
"PLATFORM_SCHEMA",
+ "PLATFORM_SCHEMA_BASE",
"NumberDeviceClass",
"NumberEntity",
"NumberEntityDescription",
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index 91a9d6adfe4..1a9c6c91ca7 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -363,7 +363,7 @@ class NumberDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
- Unit of measurement: `V`, `mV`, `µV`
+ Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV`
"""
VOLUME = "volume"
diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py
index 8882bb22a0d..6dd85e000bd 100644
--- a/homeassistant/components/number/device_action.py
+++ b/homeassistant/components/number/device_action.py
@@ -13,8 +13,7 @@ from homeassistant.const import (
CONF_TYPE,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE
diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py
index 966c51e98e9..b1b44966d14 100644
--- a/homeassistant/components/nut/config_flow.py
+++ b/homeassistant/components/nut/config_flow.py
@@ -9,7 +9,6 @@ from typing import Any
from aionut import NUTError, NUTLoginError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -27,6 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import PyNUTData
from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN
@@ -95,7 +95,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
self.reauth_entry: ConfigEntry | None = None
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a discovered nut device."""
await self._async_handle_discovery_without_unique_id()
diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py
index a051f843226..ffaa195deaf 100644
--- a/homeassistant/components/nut/device_action.py
+++ b/homeassistant/components/nut/device_action.py
@@ -7,8 +7,7 @@ import voluptuous as vol
from homeassistant.components.device_automation import InvalidDeviceAutomationConfig
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import device_registry as dr
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import NutRuntimeData
diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json
index ec5905fc16c..83b8d340dc1 100644
--- a/homeassistant/components/nut/strings.json
+++ b/homeassistant/components/nut/strings.json
@@ -113,15 +113,15 @@
"input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" },
"input_bypass_frequency": { "name": "Input bypass frequency" },
"input_bypass_phases": { "name": "Input bypass phases" },
- "input_bypass_realpower": { "name": "Current input bypass real power" },
+ "input_bypass_realpower": { "name": "Input bypass real power" },
"input_bypass_l1_realpower": {
- "name": "Current input bypass L1 real power"
+ "name": "Input bypass L1 real power"
},
"input_bypass_l2_realpower": {
- "name": "Current input bypass L2 real power"
+ "name": "Input bypass L2 real power"
},
"input_bypass_l3_realpower": {
- "name": "Current input bypass L3 real power"
+ "name": "Input bypass L3 real power"
},
"input_current": { "name": "Input current" },
"input_l1_current": { "name": "Input L1 current" },
@@ -134,10 +134,10 @@
"input_l2_frequency": { "name": "Input L2 line frequency" },
"input_l3_frequency": { "name": "Input L3 line frequency" },
"input_phases": { "name": "Input phases" },
- "input_realpower": { "name": "Current input real power" },
- "input_l1_realpower": { "name": "Current input L1 real power" },
- "input_l2_realpower": { "name": "Current input L2 real power" },
- "input_l3_realpower": { "name": "Current input L3 real power" },
+ "input_realpower": { "name": "Input real power" },
+ "input_l1_realpower": { "name": "Input L1 real power" },
+ "input_l2_realpower": { "name": "Input L2 real power" },
+ "input_l3_realpower": { "name": "Input L3 real power" },
"input_sensitivity": { "name": "Input power sensitivity" },
"input_transfer_high": { "name": "High voltage transfer" },
"input_transfer_low": { "name": "Low voltage transfer" },
@@ -160,11 +160,11 @@
"output_l1_power_percent": { "name": "Output L1 power usage" },
"output_l3_power_percent": { "name": "Output L3 power usage" },
"output_power_nominal": { "name": "Nominal output power" },
- "output_realpower": { "name": "Current output real power" },
+ "output_realpower": { "name": "Output real power" },
"output_realpower_nominal": { "name": "Nominal output real power" },
- "output_l1_realpower": { "name": "Current output L1 real power" },
- "output_l2_realpower": { "name": "Current output L2 real power" },
- "output_l3_realpower": { "name": "Current output L3 real power" },
+ "output_l1_realpower": { "name": "Output L1 real power" },
+ "output_l2_realpower": { "name": "Output L2 real power" },
+ "output_l3_realpower": { "name": "Output L3 real power" },
"output_voltage": { "name": "Output voltage" },
"output_voltage_nominal": { "name": "Nominal output voltage" },
"output_l1_n_voltage": { "name": "Output L1-N voltage" },
@@ -183,9 +183,9 @@
"ups_id": { "name": "System identifier" },
"ups_load": { "name": "Load" },
"ups_load_high": { "name": "Overload setting" },
- "ups_power": { "name": "Current apparent power" },
+ "ups_power": { "name": "Apparent power" },
"ups_power_nominal": { "name": "Nominal power" },
- "ups_realpower": { "name": "Current real power" },
+ "ups_realpower": { "name": "Real power" },
"ups_realpower_nominal": { "name": "Nominal real power" },
"ups_shutdown": { "name": "Shutdown ability" },
"ups_start_auto": { "name": "Start on ac" },
diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py
index 04e79716423..69e2f626049 100644
--- a/homeassistant/components/nx584/binary_sensor.py
+++ b/homeassistant/components/nx584/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py
index fef4cef48af..ddf4942ef25 100644
--- a/homeassistant/components/oasa_telematics/sensor.py
+++ b/homeassistant/components/oasa_telematics/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py
index 559900db5d0..03f6348ebac 100644
--- a/homeassistant/components/obihai/config_flow.py
+++ b/homeassistant/components/obihai/config_flow.py
@@ -8,11 +8,11 @@ from typing import Any
from pyobihai import PyObihai
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .connectivity import validate_auth
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN
@@ -54,7 +54,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 2
discovery_schema: vol.Schema | None = None
- _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None
+ _dhcp_discovery_info: DhcpServiceInfo | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -94,7 +94,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a DHCP discovered Obihai."""
diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py
index 7a9f3990435..59fd04357eb 100644
--- a/homeassistant/components/octoprint/__init__.py
+++ b/homeassistant/components/octoprint/__init__.py
@@ -28,8 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import slugify as util_slugify
from homeassistant.util.ssl import get_default_context, get_default_no_verify_context
diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py
index 9bbf21d71fa..010b45e5a1c 100644
--- a/homeassistant/components/octoprint/config_flow.py
+++ b/homeassistant/components/octoprint/config_flow.py
@@ -12,7 +12,6 @@ from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException
import voluptuous as vol
from yarl import URL
-from homeassistant.components import ssdp, zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
@@ -25,7 +24,9 @@ from homeassistant.const import (
)
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import get_default_context, get_default_no_verify_context
from .const import DOMAIN
@@ -167,7 +168,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_user(import_data)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery flow."""
uuid = discovery_info.properties["uuid"]
@@ -193,7 +194,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle ssdp discovery flow."""
uuid = discovery_info.upnp["UDN"][5:]
diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py
index ff00b6c3420..d4f8f652b80 100644
--- a/homeassistant/components/octoprint/coordinator.py
+++ b/homeassistant/components/octoprint/coordinator.py
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -80,7 +80,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator):
"""Device info."""
unique_id = cast(str, self.config_entry.unique_id)
configuration_url = URL.build(
- scheme=self.config_entry.data[CONF_SSL] and "https" or "http",
+ scheme=(self.config_entry.data[CONF_SSL] and "https") or "http",
host=self.config_entry.data[CONF_HOST],
port=self.config_entry.data[CONF_PORT],
path=self.config_entry.data[CONF_PATH],
diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json
index 5687ab36033..7f08d04e3da 100644
--- a/homeassistant/components/octoprint/strings.json
+++ b/homeassistant/components/octoprint/strings.json
@@ -1,11 +1,11 @@
{
"config": {
- "flow_title": "OctoPrint Printer: {host}",
+ "flow_title": "OctoPrint printer: {host}",
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
- "path": "Application Path",
+ "path": "Application path",
"port": "[%key:common::config_flow::data::port%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]",
@@ -29,7 +29,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "auth_failed": "Failed to retrieve application api key",
+ "auth_failed": "Failed to retrieve API key",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"progress": {
@@ -44,7 +44,7 @@
"services": {
"printer_connect": {
"name": "Connect to a printer",
- "description": "Instructs the octoprint server to connect to a printer.",
+ "description": "Instructs the OctoPrint server to connect to a printer.",
"fields": {
"device_id": {
"name": "Server",
diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py
index 4cecb9ff195..e5ccdf6ede8 100644
--- a/homeassistant/components/oem/climate.py
+++ b/homeassistant/components/oem/climate.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py
index b32db33cc2d..287842178d8 100644
--- a/homeassistant/components/ohmconnect/sensor.py
+++ b/homeassistant/components/ohmconnect/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py
index 4dc75cb574c..8518e55c0a3 100644
--- a/homeassistant/components/ohme/__init__.py
+++ b/homeassistant/components/ohme/__init__.py
@@ -8,9 +8,18 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
-from .coordinator import OhmeAdvancedSettingsCoordinator, OhmeChargeSessionCoordinator
+from .coordinator import (
+ OhmeAdvancedSettingsCoordinator,
+ OhmeChargeSessionCoordinator,
+ OhmeDeviceInfoCoordinator,
+)
+from .services import async_setup_services
+
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData]
@@ -21,6 +30,14 @@ class OhmeRuntimeData:
charge_session_coordinator: OhmeChargeSessionCoordinator
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
+ device_info_coordinator: OhmeDeviceInfoCoordinator
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Ohme integration."""
+ async_setup_services(hass)
+
+ return True
async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
@@ -47,6 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool
coordinators = (
OhmeChargeSessionCoordinator(hass, client),
OhmeAdvancedSettingsCoordinator(hass, client),
+ OhmeDeviceInfoCoordinator(hass, client),
)
for coordinator in coordinators:
diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py
index 21792770bb4..0b0590428ce 100644
--- a/homeassistant/components/ohme/button.py
+++ b/homeassistant/components/ohme/button.py
@@ -24,7 +24,6 @@ class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription):
"""Class describing Ohme button entities."""
press_fn: Callable[[OhmeApiClient], Awaitable[None]]
- available_fn: Callable[[OhmeApiClient], bool]
BUTTON_DESCRIPTIONS = [
@@ -67,11 +66,3 @@ class OhmeButton(OhmeEntity, ButtonEntity):
translation_key="api_failed", translation_domain=DOMAIN
) from e
await self.coordinator.async_request_refresh()
-
- @property
- def available(self) -> bool:
- """Is entity available."""
-
- return super().available and self.entity_description.available_fn(
- self.coordinator.client
- )
diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py
index b44262ad509..d97f6e3cfd7 100644
--- a/homeassistant/components/ohme/const.py
+++ b/homeassistant/components/ohme/const.py
@@ -3,4 +3,11 @@
from homeassistant.const import Platform
DOMAIN = "ohme"
-PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
+PLATFORMS = [
+ Platform.BUTTON,
+ Platform.NUMBER,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.TIME,
+]
diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py
index 5de59b3d4b2..199eb7380a7 100644
--- a/homeassistant/components/ohme/coordinator.py
+++ b/homeassistant/components/ohme/coordinator.py
@@ -53,7 +53,7 @@ class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
coordinator_name = "Charge Sessions"
_default_update_interval = timedelta(seconds=30)
- async def _internal_update_data(self):
+ async def _internal_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_get_charge_session()
@@ -63,6 +63,17 @@ class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
coordinator_name = "Advanced Settings"
- async def _internal_update_data(self):
+ async def _internal_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_get_advanced_settings()
+
+
+class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
+ """Coordinator to pull device info and charger settings from the API."""
+
+ coordinator_name = "Device Info"
+ _default_update_interval = timedelta(minutes=30)
+
+ async def _internal_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ await self.client.async_update_device_info()
diff --git a/homeassistant/components/ohme/entity.py b/homeassistant/components/ohme/entity.py
index 6a7d0ea16e4..38e281975a0 100644
--- a/homeassistant/components/ohme/entity.py
+++ b/homeassistant/components/ohme/entity.py
@@ -18,17 +18,19 @@ class OhmeEntityDescription(EntityDescription):
"""Class describing Ohme entities."""
is_supported_fn: Callable[[OhmeApiClient], bool] = lambda _: True
+ available_fn: Callable[[OhmeApiClient], bool] = lambda _: True
class OhmeEntity(CoordinatorEntity[OhmeBaseCoordinator]):
"""Base class for all Ohme entities."""
_attr_has_entity_name = True
+ entity_description: OhmeEntityDescription
def __init__(
self,
coordinator: OhmeBaseCoordinator,
- entity_description: EntityDescription,
+ entity_description: OhmeEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
@@ -51,4 +53,8 @@ class OhmeEntity(CoordinatorEntity[OhmeBaseCoordinator]):
@property
def available(self) -> bool:
"""Return if charger reporting as online."""
- return super().available and self.coordinator.client.available
+ return (
+ super().available
+ and self.coordinator.client.available
+ and self.entity_description.available_fn(self.coordinator.client)
+ )
diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json
index d5bf3fa1187..7a27156b2fe 100644
--- a/homeassistant/components/ohme/icons.json
+++ b/homeassistant/components/ohme/icons.json
@@ -5,6 +5,16 @@
"default": "mdi:check-decagram"
}
},
+ "number": {
+ "target_percentage": {
+ "default": "mdi:battery-heart"
+ }
+ },
+ "select": {
+ "charge_mode": {
+ "default": "mdi:play-box"
+ }
+ },
"sensor": {
"status": {
"default": "mdi:car",
@@ -12,12 +22,40 @@
"unplugged": "mdi:power-plug-off",
"plugged_in": "mdi:power-plug",
"charging": "mdi:battery-charging-100",
+ "paused": "mdi:pause",
"pending_approval": "mdi:alert-decagram"
}
},
"ct_current": {
"default": "mdi:gauge"
}
+ },
+ "switch": {
+ "lock_buttons": {
+ "default": "mdi:lock",
+ "state": {
+ "off": "mdi:lock-open"
+ }
+ },
+ "require_approval": {
+ "default": "mdi:check-decagram"
+ },
+ "sleep_when_inactive": {
+ "default": "mdi:sleep",
+ "state": {
+ "off": "mdi:sleep-off"
+ }
+ }
+ },
+ "time": {
+ "target_time": {
+ "default": "mdi:clock-end"
+ }
+ }
+ },
+ "services": {
+ "list_charge_slots": {
+ "service": "mdi:clock-start"
}
}
}
diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json
index 4ab0697bbb7..602c53ced7b 100644
--- a/homeassistant/components/ohme/manifest.json
+++ b/homeassistant/components/ohme/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "silver",
- "requirements": ["ohme==1.2.0"]
+ "requirements": ["ohme==1.2.8"]
}
diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py
new file mode 100644
index 00000000000..d618d4a873b
--- /dev/null
+++ b/homeassistant/components/ohme/number.py
@@ -0,0 +1,77 @@
+"""Platform for number."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from ohme import ApiException, OhmeApiClient
+
+from homeassistant.components.number import NumberEntity, NumberEntityDescription
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import OhmeConfigEntry
+from .const import DOMAIN
+from .entity import OhmeEntity, OhmeEntityDescription
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription):
+ """Class describing Ohme number entities."""
+
+ set_fn: Callable[[OhmeApiClient, float], Awaitable[None]]
+ value_fn: Callable[[OhmeApiClient], float]
+
+
+NUMBER_DESCRIPTION = [
+ OhmeNumberDescription(
+ key="target_percentage",
+ translation_key="target_percentage",
+ value_fn=lambda client: client.target_soc,
+ set_fn=lambda client, value: client.async_set_target(target_percent=value),
+ native_min_value=0,
+ native_max_value=100,
+ native_step=1,
+ native_unit_of_measurement=PERCENTAGE,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OhmeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up numbers."""
+ coordinators = config_entry.runtime_data
+ coordinator = coordinators.charge_session_coordinator
+
+ async_add_entities(
+ OhmeNumber(coordinator, description)
+ for description in NUMBER_DESCRIPTION
+ if description.is_supported_fn(coordinator.client)
+ )
+
+
+class OhmeNumber(OhmeEntity, NumberEntity):
+ """Generic number entity for Ohme."""
+
+ entity_description: OhmeNumberDescription
+
+ @property
+ def native_value(self) -> float:
+ """Return the current value of the number."""
+ return self.entity_description.value_fn(self.coordinator.client)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the number value."""
+ try:
+ await self.entity_description.set_fn(self.coordinator.client, value)
+ except ApiException as e:
+ raise HomeAssistantError(
+ translation_key="api_failed", translation_domain=DOMAIN
+ ) from e
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml
index 7fc2f55e2f9..497d5ad32e5 100644
--- a/homeassistant/components/ohme/quality_scale.yaml
+++ b/homeassistant/components/ohme/quality_scale.yaml
@@ -1,19 +1,13 @@
rules:
# Bronze
- action-setup:
- status: exempt
- comment: |
- This integration has no custom actions.
+ action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
- docs-actions:
- status: exempt
- comment: |
- This integration has no custom actions.
+ docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py
new file mode 100644
index 00000000000..a357e98f0a6
--- /dev/null
+++ b/homeassistant/components/ohme/select.py
@@ -0,0 +1,70 @@
+"""Platform for Ohme selects."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any, Final
+
+from ohme import ApiException, ChargerMode, OhmeApiClient
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import OhmeConfigEntry
+from .const import DOMAIN
+from .entity import OhmeEntity, OhmeEntityDescription
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription):
+ """Class to describe an Ohme select entity."""
+
+ select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]]
+ current_option_fn: Callable[[OhmeApiClient], str | None]
+
+
+SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription(
+ key="charge_mode",
+ translation_key="charge_mode",
+ select_fn=lambda client, mode: client.async_set_mode(mode),
+ options=[e.value for e in ChargerMode],
+ current_option_fn=lambda client: client.mode.value if client.mode else None,
+ available_fn=lambda client: client.mode is not None,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OhmeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Ohme selects."""
+ coordinator = config_entry.runtime_data.charge_session_coordinator
+
+ async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)])
+
+
+class OhmeSelect(OhmeEntity, SelectEntity):
+ """Ohme select entity."""
+
+ entity_description: OhmeSelectDescription
+
+ async def async_select_option(self, option: str) -> None:
+ """Handle the selection of an option."""
+ try:
+ await self.entity_description.select_fn(self.coordinator.client, option)
+ except ApiException as e:
+ raise HomeAssistantError(
+ translation_key="api_failed", translation_domain=DOMAIN
+ ) from e
+ await self.coordinator.async_request_refresh()
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the current selected option."""
+ return self.entity_description.current_option_fn(self.coordinator.client)
diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py
index 6d111cf7af6..230314cba83 100644
--- a/homeassistant/components/ohme/sensor.py
+++ b/homeassistant/components/ohme/sensor.py
@@ -13,7 +13,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower
+from homeassistant.const import (
+ PERCENTAGE,
+ UnitOfElectricCurrent,
+ UnitOfEnergy,
+ UnitOfPower,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -61,6 +66,14 @@ SENSOR_CHARGE_SESSION = [
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda client: client.energy,
),
+ OhmeSensorDescription(
+ key="battery",
+ translation_key="vehicle_battery",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ suggested_display_precision=0,
+ value_fn=lambda client: client.battery,
+ ),
]
SENSOR_ADVANCED_SETTINGS = [
diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py
new file mode 100644
index 00000000000..7d06b909d88
--- /dev/null
+++ b/homeassistant/components/ohme/services.py
@@ -0,0 +1,75 @@
+"""Ohme services."""
+
+from typing import Final
+
+from ohme import OhmeApiClient
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import selector
+
+from .const import DOMAIN
+
+SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots"
+ATTR_CONFIG_ENTRY: Final = "config_entry"
+SERVICE_SCHEMA: Final = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ ),
+ }
+)
+
+
+def __get_client(call: ServiceCall) -> OhmeApiClient:
+ """Get the client from the config entry."""
+ entry_id: str = call.data[ATTR_CONFIG_ENTRY]
+ entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id)
+
+ if not entry:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_config_entry",
+ translation_placeholders={
+ "config_entry": entry_id,
+ },
+ )
+ if entry.state != ConfigEntryState.LOADED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="unloaded_config_entry",
+ translation_placeholders={
+ "config_entry": entry.title,
+ },
+ )
+
+ return entry.runtime_data.charge_session_coordinator.client
+
+
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register services."""
+
+ async def list_charge_slots(
+ service_call: ServiceCall,
+ ) -> ServiceResponse:
+ """List of charge slots."""
+ client = __get_client(service_call)
+
+ return {"slots": client.slots}
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_LIST_CHARGE_SLOTS,
+ list_charge_slots,
+ schema=SERVICE_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/ohme/services.yaml b/homeassistant/components/ohme/services.yaml
new file mode 100644
index 00000000000..c5c8ee18138
--- /dev/null
+++ b/homeassistant/components/ohme/services.yaml
@@ -0,0 +1,7 @@
+list_charge_slots:
+ fields:
+ config_entry:
+ required: true
+ selector:
+ config_entry:
+ integration: ohme
diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json
index 125babc1901..eb5bbffda52 100644
--- a/homeassistant/components/ohme/strings.json
+++ b/homeassistant/components/ohme/strings.json
@@ -32,12 +32,39 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
+ "services": {
+ "list_charge_slots": {
+ "name": "List charge slots",
+ "description": "Return a list of charge slots.",
+ "fields": {
+ "config_entry": {
+ "name": "Ohme account",
+ "description": "The Ohme config entry for which to return charge slots."
+ }
+ }
+ }
+ },
"entity": {
"button": {
"approve": {
"name": "Approve charge"
}
},
+ "number": {
+ "target_percentage": {
+ "name": "Target percentage"
+ }
+ },
+ "select": {
+ "charge_mode": {
+ "name": "Charge mode",
+ "state": {
+ "smart_charge": "Smart charge",
+ "max_charge": "Max charge",
+ "paused": "Paused"
+ }
+ }
+ },
"sensor": {
"status": {
"name": "Status",
@@ -45,11 +72,31 @@
"unplugged": "Unplugged",
"plugged_in": "Plugged in",
"charging": "Charging",
+ "paused": "[%key:common::state::paused%]",
"pending_approval": "Pending approval"
}
},
"ct_current": {
"name": "CT current"
+ },
+ "vehicle_battery": {
+ "name": "Vehicle battery"
+ }
+ },
+ "switch": {
+ "lock_buttons": {
+ "name": "Lock buttons"
+ },
+ "require_approval": {
+ "name": "Require approval"
+ },
+ "sleep_when_inactive": {
+ "name": "Sleep when inactive"
+ }
+ },
+ "time": {
+ "target_time": {
+ "name": "Target time"
}
}
},
@@ -62,6 +109,12 @@
},
"api_failed": {
"message": "Error communicating with Ohme API"
+ },
+ "invalid_config_entry": {
+ "message": "Invalid config entry provided. Got {config_entry}"
+ },
+ "unloaded_config_entry": {
+ "message": "Invalid config entry provided. {config_entry} is not loaded."
}
}
}
diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py
new file mode 100644
index 00000000000..d1eb1a80b56
--- /dev/null
+++ b/homeassistant/components/ohme/switch.py
@@ -0,0 +1,102 @@
+"""Platform for switch."""
+
+from dataclasses import dataclass
+from typing import Any
+
+from ohme import ApiException
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import OhmeConfigEntry
+from .const import DOMAIN
+from .entity import OhmeEntity, OhmeEntityDescription
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription):
+ """Class describing Ohme switch entities."""
+
+ configuration_key: str
+
+
+SWITCH_DEVICE_INFO = [
+ OhmeSwitchDescription(
+ key="lock_buttons",
+ translation_key="lock_buttons",
+ entity_category=EntityCategory.CONFIG,
+ is_supported_fn=lambda client: client.is_capable("buttonsLockable"),
+ configuration_key="buttonsLocked",
+ ),
+ OhmeSwitchDescription(
+ key="require_approval",
+ translation_key="require_approval",
+ entity_category=EntityCategory.CONFIG,
+ is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"),
+ configuration_key="pluginsRequireApproval",
+ ),
+ OhmeSwitchDescription(
+ key="sleep_when_inactive",
+ translation_key="sleep_when_inactive",
+ entity_category=EntityCategory.CONFIG,
+ is_supported_fn=lambda client: client.is_capable("stealth"),
+ configuration_key="stealthEnabled",
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OhmeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up switches."""
+ coordinators = config_entry.runtime_data
+ coordinator_map = [
+ (SWITCH_DEVICE_INFO, coordinators.device_info_coordinator),
+ ]
+
+ async_add_entities(
+ OhmeSwitch(coordinator, description)
+ for entities, coordinator in coordinator_map
+ for description in entities
+ if description.is_supported_fn(coordinator.client)
+ )
+
+
+class OhmeSwitch(OhmeEntity, SwitchEntity):
+ """Generic switch for Ohme."""
+
+ entity_description: OhmeSwitchDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return the entity value to represent the entity state."""
+ return self.coordinator.client.configuration_value(
+ self.entity_description.configuration_key
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self._toggle(True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self._toggle(False)
+
+ async def _toggle(self, on: bool) -> None:
+ """Toggle the switch."""
+ try:
+ await self.coordinator.client.async_set_configuration_value(
+ {self.entity_description.configuration_key: on}
+ )
+ except ApiException as e:
+ raise HomeAssistantError(
+ translation_key="api_failed", translation_domain=DOMAIN
+ ) from e
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py
new file mode 100644
index 00000000000..a7de913ef8e
--- /dev/null
+++ b/homeassistant/components/ohme/time.py
@@ -0,0 +1,77 @@
+"""Platform for time."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import time
+
+from ohme import ApiException, OhmeApiClient
+
+from homeassistant.components.time import TimeEntity, TimeEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import OhmeConfigEntry
+from .const import DOMAIN
+from .entity import OhmeEntity, OhmeEntityDescription
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription):
+ """Class describing Ohme time entities."""
+
+ set_fn: Callable[[OhmeApiClient, time], Awaitable[None]]
+ value_fn: Callable[[OhmeApiClient], time]
+
+
+TIME_DESCRIPTION = [
+ OhmeTimeDescription(
+ key="target_time",
+ translation_key="target_time",
+ value_fn=lambda client: time(
+ hour=client.target_time[0], minute=client.target_time[1]
+ ),
+ set_fn=lambda client, value: client.async_set_target(
+ target_time=(value.hour, value.minute)
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OhmeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up time entities."""
+ coordinators = config_entry.runtime_data
+ coordinator = coordinators.charge_session_coordinator
+
+ async_add_entities(
+ OhmeTime(coordinator, description)
+ for description in TIME_DESCRIPTION
+ if description.is_supported_fn(coordinator.client)
+ )
+
+
+class OhmeTime(OhmeEntity, TimeEntity):
+ """Generic time entity for Ohme."""
+
+ entity_description: OhmeTimeDescription
+
+ @property
+ def native_value(self) -> time:
+ """Return the current value of the time."""
+ return self.entity_description.value_fn(self.coordinator.client)
+
+ async def async_set_value(self, value: time) -> None:
+ """Set the time value."""
+ try:
+ await self.entity_description.set_fn(self.coordinator.client, value)
+ except ApiException as e:
+ raise HomeAssistantError(
+ translation_key="api_failed", translation_domain=DOMAIN
+ ) from e
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py
index 3bcba567803..6983db73cf4 100644
--- a/homeassistant/components/ollama/__init__.py
+++ b/homeassistant/components/ollama/__init__.py
@@ -28,12 +28,12 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
__all__ = [
- "CONF_URL",
- "CONF_PROMPT",
- "CONF_MODEL",
- "CONF_MAX_HISTORY",
- "CONF_NUM_CTX",
"CONF_KEEP_ALIVE",
+ "CONF_MAX_HISTORY",
+ "CONF_MODEL",
+ "CONF_NUM_CTX",
+ "CONF_PROMPT",
+ "CONF_URL",
"DOMAIN",
]
diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py
index 1a91c790d27..c0fbfae6444 100644
--- a/homeassistant/components/ollama/conversation.py
+++ b/homeassistant/components/ollama/conversation.py
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import intent, llm, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import ulid
+from homeassistant.util import ulid as ulid_util
from .const import (
CONF_KEEP_ALIVE,
@@ -141,7 +141,7 @@ class OllamaConversationEntity(
settings = {**self.entry.data, **self.entry.options}
client = self.hass.data[DOMAIN][self.entry.entry_id]
- conversation_id = user_input.conversation_id or ulid.ulid_now()
+ conversation_id = user_input.conversation_id or ulid_util.ulid_now()
model = settings[CONF_MODEL]
intent_response = intent.IntentResponse(language=user_input.language)
llm_api: llm.APIInstance | None = None
diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json
index dbecbf87e4e..c3f7616ca16 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.4.5"]
+ "requirements": ["ollama==0.4.7"]
}
diff --git a/homeassistant/components/ombi/__init__.py b/homeassistant/components/ombi/__init__.py
index d63f72592f8..c3a51bacce2 100644
--- a/homeassistant/components/ombi/__init__.py
+++ b/homeassistant/components/ombi/__init__.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json
index 918d845993a..8e253d4bff9 100644
--- a/homeassistant/components/onboarding/manifest.json
+++ b/homeassistant/components/onboarding/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "onboarding",
"name": "Home Assistant Onboarding",
- "after_dependencies": ["hassio"],
+ "after_dependencies": ["backup", "hassio"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["auth", "http", "person"],
"documentation": "https://www.home-assistant.io/integrations/onboarding",
diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py
index b33440a9eb7..1e29860e3c5 100644
--- a/homeassistant/components/onboarding/views.py
+++ b/homeassistant/components/onboarding/views.py
@@ -3,9 +3,10 @@
from __future__ import annotations
import asyncio
-from collections.abc import Coroutine
+from collections.abc import Callable, Coroutine
+from functools import wraps
from http import HTTPStatus
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, Concatenate, cast
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
@@ -15,10 +16,18 @@ from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.providers.homeassistant import HassAuthProvider
from homeassistant.components import person
from homeassistant.components.auth import indieauth
+from homeassistant.components.backup import (
+ BackupManager,
+ Folder,
+ IncorrectPasswordError,
+ async_get_manager as async_get_backup_manager,
+ http as backup_http,
+)
from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.system_info import async_get_system_info
@@ -50,6 +59,9 @@ async def async_setup(
hass.http.register_view(CoreConfigOnboardingView(data, store))
hass.http.register_view(IntegrationOnboardingView(data, store))
hass.http.register_view(AnalyticsOnboardingView(data, store))
+ hass.http.register_view(BackupInfoView(data))
+ hass.http.register_view(RestoreBackupView(data))
+ hass.http.register_view(UploadBackupView(data))
class OnboardingView(HomeAssistantView):
@@ -312,6 +324,119 @@ class AnalyticsOnboardingView(_BaseOnboardingView):
return self.json({})
+class BackupOnboardingView(HomeAssistantView):
+ """Backup onboarding view."""
+
+ requires_auth = False
+
+ def __init__(self, data: OnboardingStoreData) -> None:
+ """Initialize the view."""
+ self._data = data
+
+
+def with_backup_manager[_ViewT: BackupOnboardingView, **_P](
+ func: Callable[
+ Concatenate[_ViewT, BackupManager, web.Request, _P],
+ Coroutine[Any, Any, web.Response],
+ ],
+) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
+ """Home Assistant API decorator to check onboarding and inject manager."""
+
+ @wraps(func)
+ async def with_backup(
+ self: _ViewT,
+ request: web.Request,
+ *args: _P.args,
+ **kwargs: _P.kwargs,
+ ) -> web.Response:
+ """Check admin and call function."""
+ if self._data["done"]:
+ raise HTTPUnauthorized
+
+ try:
+ manager = async_get_backup_manager(request.app[KEY_HASS])
+ except HomeAssistantError:
+ return self.json(
+ {"error": "backup_disabled"},
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ )
+
+ return await func(self, manager, request, *args, **kwargs)
+
+ return with_backup
+
+
+class BackupInfoView(BackupOnboardingView):
+ """Get backup info view."""
+
+ url = "/api/onboarding/backup/info"
+ name = "api:onboarding:backup:info"
+
+ @with_backup_manager
+ async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
+ """Return backup info."""
+ backups, _ = await manager.async_get_backups()
+ return self.json(
+ {
+ "backups": list(backups.values()),
+ "state": manager.state,
+ "last_non_idle_event": manager.last_non_idle_event,
+ }
+ )
+
+
+class RestoreBackupView(BackupOnboardingView):
+ """Restore backup view."""
+
+ url = "/api/onboarding/backup/restore"
+ name = "api:onboarding:backup:restore"
+
+ @RequestDataValidator(
+ vol.Schema(
+ {
+ vol.Required("backup_id"): str,
+ vol.Required("agent_id"): str,
+ vol.Optional("password"): str,
+ vol.Optional("restore_addons"): [str],
+ vol.Optional("restore_database", default=True): bool,
+ vol.Optional("restore_folders"): [vol.Coerce(Folder)],
+ }
+ )
+ )
+ @with_backup_manager
+ async def post(
+ self, manager: BackupManager, request: web.Request, data: dict[str, Any]
+ ) -> web.Response:
+ """Restore a backup."""
+ try:
+ await manager.async_restore_backup(
+ data["backup_id"],
+ agent_id=data["agent_id"],
+ password=data.get("password"),
+ restore_addons=data.get("restore_addons"),
+ restore_database=data["restore_database"],
+ restore_folders=data.get("restore_folders"),
+ restore_homeassistant=True,
+ )
+ except IncorrectPasswordError:
+ return self.json(
+ {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
+ )
+ return web.Response(status=HTTPStatus.OK)
+
+
+class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView):
+ """Upload backup view."""
+
+ url = "/api/onboarding/backup/upload"
+ name = "api:onboarding:backup:upload"
+
+ @with_backup_manager
+ async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
+ """Upload a backup file."""
+ return await self._post(request)
+
+
@callback
def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider:
"""Get the Home Assistant auth provider."""
diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py
new file mode 100644
index 00000000000..5feefb2cf7d
--- /dev/null
+++ b/homeassistant/components/onedrive/__init__.py
@@ -0,0 +1,110 @@
+"""The OneDrive integration."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+import logging
+from typing import cast
+
+from onedrive_personal_sdk import OneDriveClient
+from onedrive_personal_sdk.exceptions import (
+ AuthenticationError,
+ HttpRequestException,
+ OneDriveException,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ OAuth2Session,
+ async_get_config_entry_implementation,
+)
+from homeassistant.helpers.instance_id import async_get as async_get_instance_id
+
+from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+
+
+@dataclass
+class OneDriveRuntimeData:
+ """Runtime data for the OneDrive integration."""
+
+ client: OneDriveClient
+ token_function: Callable[[], Awaitable[str]]
+ backup_folder_id: str
+
+
+type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
+ """Set up OneDrive from a config entry."""
+ implementation = await async_get_config_entry_implementation(hass, entry)
+
+ session = OAuth2Session(hass, entry, implementation)
+
+ async def get_access_token() -> str:
+ await session.async_ensure_token_valid()
+ return cast(str, session.token[CONF_ACCESS_TOKEN])
+
+ client = OneDriveClient(get_access_token, async_get_clientsession(hass))
+
+ # get approot, will be created automatically if it does not exist
+ try:
+ approot = await client.get_approot()
+ except AuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from err
+ except (HttpRequestException, OneDriveException, TimeoutError) as err:
+ _LOGGER.debug("Failed to get approot", exc_info=True)
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="failed_to_get_folder",
+ translation_placeholders={"folder": "approot"},
+ ) from err
+
+ instance_id = await async_get_instance_id(hass)
+ backup_folder_name = f"backups_{instance_id[:8]}"
+ try:
+ backup_folder = await client.create_folder(
+ parent_id=approot.id, name=backup_folder_name
+ )
+ except (HttpRequestException, OneDriveException, TimeoutError) as err:
+ _LOGGER.debug("Failed to create backup folder", exc_info=True)
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="failed_to_get_folder",
+ translation_placeholders={"folder": backup_folder_name},
+ ) from err
+
+ entry.runtime_data = OneDriveRuntimeData(
+ client=client,
+ token_function=get_access_token,
+ backup_folder_id=backup_folder.id,
+ )
+
+ _async_notify_backup_listeners_soon(hass)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
+ """Unload a OneDrive config entry."""
+ _async_notify_backup_listeners_soon(hass)
+ return True
+
+
+def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+
+@callback
+def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
+ hass.loop.call_soon(_async_notify_backup_listeners, hass)
diff --git a/homeassistant/components/onedrive/application_credentials.py b/homeassistant/components/onedrive/application_credentials.py
new file mode 100644
index 00000000000..b38aa9313d0
--- /dev/null
+++ b/homeassistant/components/onedrive/application_credentials.py
@@ -0,0 +1,14 @@
+"""Application credentials platform for the OneDrive integration."""
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ authorize_url=OAUTH2_AUTHORIZE,
+ token_url=OAUTH2_TOKEN,
+ )
diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py
new file mode 100644
index 00000000000..78bdcb24b8c
--- /dev/null
+++ b/homeassistant/components/onedrive/backup.py
@@ -0,0 +1,215 @@
+"""Support for OneDrive backup."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+from functools import wraps
+import html
+import json
+import logging
+from typing import Any, Concatenate
+
+from aiohttp import ClientTimeout
+from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient
+from onedrive_personal_sdk.exceptions import (
+ AuthenticationError,
+ HashMismatchError,
+ OneDriveException,
+)
+from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate
+from onedrive_personal_sdk.models.upload import FileInfo
+
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ suggested_filename,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from . import OneDriveConfigEntry
+from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
+TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+) -> list[BackupAgent]:
+ """Return a list of backup agents."""
+ entries: list[OneDriveConfigEntry] = hass.config_entries.async_loaded_entries(
+ DOMAIN
+ )
+ return [OneDriveBackupAgent(hass, entry) for entry in entries]
+
+
+@callback
+def async_register_backup_agents_listener(
+ hass: HomeAssistant,
+ *,
+ listener: Callable[[], None],
+ **kwargs: Any,
+) -> Callable[[], None]:
+ """Register a listener to be called when agents are added or removed."""
+ hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove the listener."""
+ hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
+ if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
+ del hass.data[DATA_BACKUP_AGENT_LISTENERS]
+
+ return remove_listener
+
+
+def handle_backup_errors[_R, **P](
+ func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]:
+ """Handle backup errors with a specific translation key."""
+
+ @wraps(func)
+ async def wrapper(
+ self: OneDriveBackupAgent, *args: P.args, **kwargs: P.kwargs
+ ) -> _R:
+ try:
+ return await func(self, *args, **kwargs)
+ except AuthenticationError as err:
+ self._entry.async_start_reauth(self._hass)
+ raise BackupAgentError("Authentication error") from err
+ except OneDriveException as err:
+ _LOGGER.error(
+ "Error during backup in %s:, message %s",
+ func.__name__,
+ err,
+ )
+ _LOGGER.debug("Full error: %s", err, exc_info=True)
+ raise BackupAgentError("Backup operation failed") from err
+ except TimeoutError as err:
+ _LOGGER.error(
+ "Error during backup in %s: Timeout",
+ func.__name__,
+ )
+ raise BackupAgentError("Backup operation timed out") from err
+
+ return wrapper
+
+
+class OneDriveBackupAgent(BackupAgent):
+ """OneDrive backup agent."""
+
+ domain = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
+ """Initialize the OneDrive backup agent."""
+ super().__init__()
+ self._hass = hass
+ self._entry = entry
+ self._client = entry.runtime_data.client
+ self._token_function = entry.runtime_data.token_function
+ self._folder_id = entry.runtime_data.backup_folder_id
+ self.name = entry.title
+ assert entry.unique_id
+ self.unique_id = entry.unique_id
+
+ @handle_backup_errors
+ async def async_download_backup(
+ self, backup_id: str, **kwargs: Any
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file."""
+ item = await self._find_item_by_backup_id(backup_id)
+ if item is None:
+ raise BackupAgentError("Backup not found")
+
+ stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT)
+ return stream.iter_chunked(1024)
+
+ @handle_backup_errors
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup."""
+
+ file = FileInfo(
+ suggested_filename(backup),
+ backup.size,
+ self._folder_id,
+ await open_stream(),
+ )
+ try:
+ item = await LargeFileUploadClient.upload(
+ self._token_function, file, session=async_get_clientsession(self._hass)
+ )
+ except HashMismatchError as err:
+ raise BackupAgentError(
+ "Hash validation failed, backup file might be corrupt"
+ ) from err
+
+ # store metadata in description
+ backup_dict = backup.as_dict()
+ backup_dict["metadata_version"] = 1 # version of the backup metadata
+ description = json.dumps(backup_dict)
+ _LOGGER.debug("Creating metadata: %s", description)
+
+ await self._client.update_drive_item(
+ path_or_id=item.id,
+ data=ItemUpdate(description=description),
+ )
+
+ @handle_backup_errors
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file."""
+ item = await self._find_item_by_backup_id(backup_id)
+ if item is None:
+ return
+ await self._client.delete_drive_item(item.id)
+
+ @handle_backup_errors
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ return [
+ self._backup_from_description(item.description)
+ for item in await self._client.list_drive_items(self._folder_id)
+ if item.description and "homeassistant_version" in item.description
+ ]
+
+ @handle_backup_errors
+ async def async_get_backup(
+ self, backup_id: str, **kwargs: Any
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ item = await self._find_item_by_backup_id(backup_id)
+ return (
+ self._backup_from_description(item.description)
+ if item and item.description
+ else None
+ )
+
+ def _backup_from_description(self, description: str) -> AgentBackup:
+ """Create a backup object from a description."""
+ description = html.unescape(
+ description
+ ) # OneDrive encodes the description on save automatically
+ return AgentBackup.from_dict(json.loads(description))
+
+ async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None:
+ """Find an item by backup ID."""
+ return next(
+ (
+ item
+ for item in await self._client.list_drive_items(self._folder_id)
+ if item.description and backup_id in item.description
+ ),
+ None,
+ )
diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py
new file mode 100644
index 00000000000..900db0177d9
--- /dev/null
+++ b/homeassistant/components/onedrive/config_flow.py
@@ -0,0 +1,88 @@
+"""Config flow for OneDrive."""
+
+from collections.abc import Mapping
+import logging
+from typing import Any, cast
+
+from onedrive_personal_sdk.clients.client import OneDriveClient
+from onedrive_personal_sdk.exceptions import OneDriveException
+
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
+
+from .const import DOMAIN, OAUTH_SCOPES
+
+
+class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
+ """Config flow to handle OneDrive OAuth2 authentication."""
+
+ DOMAIN = DOMAIN
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
+
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {"scope": " ".join(OAUTH_SCOPES)}
+
+ async def async_oauth_create_entry(
+ self,
+ data: dict[str, Any],
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+
+ async def get_access_token() -> str:
+ return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
+
+ graph_client = OneDriveClient(
+ get_access_token, async_get_clientsession(self.hass)
+ )
+
+ try:
+ approot = await graph_client.get_approot()
+ except OneDriveException:
+ self.logger.exception("Failed to connect to OneDrive")
+ return self.async_abort(reason="connection_error")
+ except Exception:
+ self.logger.exception("Unknown error")
+ return self.async_abort(reason="unknown")
+
+ await self.async_set_unique_id(approot.parent_reference.drive_id)
+
+ if self.source == SOURCE_REAUTH:
+ reauth_entry = self._get_reauth_entry()
+ self._abort_if_unique_id_mismatch(
+ reason="wrong_drive",
+ )
+ return self.async_update_reload_and_abort(
+ entry=reauth_entry,
+ data=data,
+ )
+
+ self._abort_if_unique_id_configured()
+
+ title = (
+ f"{approot.created_by.user.display_name}'s OneDrive"
+ if approot.created_by.user and approot.created_by.user.display_name
+ else "OneDrive"
+ )
+ return self.async_create_entry(title=title, data=data)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ return await self.async_step_user()
diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py
new file mode 100644
index 00000000000..f9d49b141e5
--- /dev/null
+++ b/homeassistant/components/onedrive/const.py
@@ -0,0 +1,24 @@
+"""Constants for the OneDrive integration."""
+
+from collections.abc import Callable
+from typing import Final
+
+from homeassistant.util.hass_dict import HassKey
+
+DOMAIN: Final = "onedrive"
+
+# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support
+OAUTH2_AUTHORIZE: Final = (
+ "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
+)
+OAUTH2_TOKEN: Final = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
+
+OAUTH_SCOPES: Final = [
+ "Files.ReadWrite.AppFolder",
+ "offline_access",
+ "openid",
+]
+
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}.backup_agent_listeners"
+)
diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json
new file mode 100644
index 00000000000..88d51e6d73a
--- /dev/null
+++ b/homeassistant/components/onedrive/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "onedrive",
+ "name": "OneDrive",
+ "codeowners": ["@zweckj"],
+ "config_flow": true,
+ "dependencies": ["application_credentials"],
+ "documentation": "https://www.home-assistant.io/integrations/onedrive",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["onedrive_personal_sdk"],
+ "quality_scale": "bronze",
+ "requirements": ["onedrive-personal-sdk==0.0.8"]
+}
diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml
new file mode 100644
index 00000000000..f0d58d89c9a
--- /dev/null
+++ b/homeassistant/components/onedrive/quality_scale.yaml
@@ -0,0 +1,139 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not have any custom actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ has-entity-name:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ No Options flow.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration does not have platforms.
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ diagnostics:
+ status: exempt
+ comment: |
+ There is no data to diagnose.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ docs-data-update:
+ status: exempt
+ comment: |
+ This integration does not poll or push.
+ docs-examples:
+ status: exempt
+ comment: |
+ This integration only serves backup.
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This integration is a cloud service.
+ docs-supported-functions:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ docs-troubleshooting:
+ status: exempt
+ comment: |
+ No issues known to troubleshoot.
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ entity-category:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-device-class:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ Nothing to reconfigure.
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json
new file mode 100644
index 00000000000..7686e83e2a5
--- /dev/null
+++ b/homeassistant/components/onedrive/strings.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "step": {
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The OneDrive integration needs to re-authenticate your account"
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "connection_error": "Failed to connect to OneDrive.",
+ "wrong_drive": "New account does not contain previously configured OneDrive.",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ },
+ "create_entry": {
+ "default": "[%key:common::config_flow::create_entry::authenticated%]"
+ }
+ },
+ "exceptions": {
+ "authentication_failed": {
+ "message": "Authentication failed"
+ },
+ "failed_to_get_folder": {
+ "message": "Failed to get {folder} folder"
+ }
+ }
+}
diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py
index 3c4aac2cd7d..c77d87d91b9 100644
--- a/homeassistant/components/onewire/__init__.py
+++ b/homeassistant/components/onewire/__init__.py
@@ -4,32 +4,40 @@ import logging
from pyownet import protocol
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
-from .const import DOMAIN, PLATFORMS
-from .onewirehub import CannotConnect, OneWireHub
+from .const import DOMAIN
+from .onewirehub import OneWireConfigEntry, OneWireHub
_LOGGER = logging.getLogger(__name__)
-type OneWireConfigEntry = ConfigEntry[OneWireHub]
+
+_PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool:
"""Set up a 1-Wire proxy for a config entry."""
- onewire_hub = OneWireHub(hass)
+ onewire_hub = OneWireHub(hass, entry)
try:
- await onewire_hub.initialize(entry)
+ await onewire_hub.initialize()
except (
- CannotConnect, # Failed to connect to the server
+ protocol.ConnError, # Failed to connect to the server
protocol.OwnetError, # Connected to server, but failed to list the devices
) as exc:
raise ConfigEntryNotReady from exc
entry.runtime_data = onewire_hub
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+
+ onewire_hub.schedule_scan_for_new_devices()
entry.async_on_unload(entry.add_update_listener(options_update_listener))
@@ -50,7 +58,7 @@ async def async_unload_entry(
hass: HomeAssistant, config_entry: OneWireConfigEntry
) -> bool:
"""Unload a config entry."""
- return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
+ return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS)
async def options_update_listener(
diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py
index 5607fd7ed1d..60a1d165b15 100644
--- a/homeassistant/components/onewire/binary_sensor.py
+++ b/homeassistant/components/onewire/binary_sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import timedelta
import os
from homeassistant.components.binary_sensor import (
@@ -12,12 +13,22 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import OneWireConfigEntry
from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL
from .entity import OneWireEntity, OneWireEntityDescription
-from .onewirehub import OneWireHub
+from .onewirehub import (
+ SIGNAL_NEW_DEVICE_CONNECTED,
+ OneWireConfigEntry,
+ OneWireHub,
+ OWDeviceDescription,
+)
+
+# the library uses non-persistent connections
+# and concurrent access to the bus is managed by the server
+PARALLEL_UPDATES = 0
+SCAN_INTERVAL = timedelta(seconds=30)
@dataclass(frozen=True)
@@ -93,19 +104,28 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
- entities = await hass.async_add_executor_job(
- get_entities, config_entry.runtime_data
+
+ async def _add_entities(
+ hub: OneWireHub, devices: list[OWDeviceDescription]
+ ) -> None:
+ """Add 1-Wire entities for all devices."""
+ if not devices:
+ return
+ async_add_entities(get_entities(hub, devices), True)
+
+ hub = config_entry.runtime_data
+ await _add_entities(hub, hub.devices)
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
- async_add_entities(entities, True)
-def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]:
+def get_entities(
+ onewire_hub: OneWireHub, devices: list[OWDeviceDescription]
+) -> list[OneWireBinarySensorEntity]:
"""Get a list of entities."""
- if not onewire_hub.devices:
- return []
-
- entities: list[OneWireBinarySensor] = []
- for device in onewire_hub.devices:
+ entities: list[OneWireBinarySensorEntity] = []
+ for device in devices:
family = device.family
device_id = device.id
device_type = device.type
@@ -120,7 +140,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]:
for description in get_sensor_types(device_sub_type)[family]:
device_file = os.path.join(os.path.split(device.path)[0], description.key)
entities.append(
- OneWireBinarySensor(
+ OneWireBinarySensorEntity(
description=description,
device_id=device_id,
device_file=device_file,
@@ -132,7 +152,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]:
return entities
-class OneWireBinarySensor(OneWireEntity, BinarySensorEntity):
+class OneWireBinarySensorEntity(OneWireEntity, BinarySensorEntity):
"""Implementation of a 1-Wire binary sensor."""
entity_description: OneWireBinarySensorEntityDescription
diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py
index 3889db2a069..8a5623772f7 100644
--- a/homeassistant/components/onewire/config_flow.py
+++ b/homeassistant/components/onewire/config_flow.py
@@ -5,18 +5,16 @@ from __future__ import annotations
from copy import deepcopy
from typing import Any
+from pyownet import protocol
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
+from homeassistant.helpers.service_info.hassio import HassioServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
DEFAULT_HOST,
@@ -29,7 +27,7 @@ from .const import (
OPTION_ENTRY_SENSOR_PRECISION,
PRECISION_MAPPING_FAMILY_28,
)
-from .onewirehub import CannotConnect, OneWireHub
+from .onewirehub import OneWireConfigEntry
DATA_SCHEMA = vol.Schema(
{
@@ -39,70 +37,124 @@ DATA_SCHEMA = vol.Schema(
)
-async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
- """Validate the user input allows us to connect.
-
- Data has the keys from DATA_SCHEMA with values provided by the user.
- """
-
- hub = OneWireHub(hass)
-
- host = data[CONF_HOST]
- port = data[CONF_PORT]
- # Raises CannotConnect exception on failure
- await hub.connect(host, port)
-
- # Return info that you want to store in the config entry.
- return {"title": host}
+async def validate_input(
+ hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str]
+) -> None:
+ """Validate the user input allows us to connect."""
+ try:
+ await hass.async_add_executor_job(
+ protocol.proxy, data[CONF_HOST], data[CONF_PORT]
+ )
+ except protocol.ConnError:
+ errors["base"] = "cannot_connect"
class OneWireFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle 1-Wire config flow."""
VERSION = 1
-
- def __init__(self) -> None:
- """Initialize 1-Wire config flow."""
- self.onewire_config: dict[str, Any] = {}
+ _discovery_data: dict[str, Any]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle 1-Wire config flow start.
-
- Let user manually input configuration.
- """
+ """Handle 1-Wire config flow start."""
errors: dict[str, str] = {}
if user_input:
- # Prevent duplicate entries
self._async_abort_entries_match(
- {
- CONF_HOST: user_input[CONF_HOST],
- CONF_PORT: user_input[CONF_PORT],
- }
+ {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
- self.onewire_config.update(user_input)
-
- try:
- info = await validate_input(self.hass, user_input)
- except CannotConnect:
- errors["base"] = "cannot_connect"
- else:
+ await validate_input(self.hass, user_input, errors)
+ if not errors:
return self.async_create_entry(
- title=info["title"], data=self.onewire_config
+ title=user_input[CONF_HOST], data=user_input
)
return self.async_show_form(
step_id="user",
- data_schema=DATA_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input),
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle 1-Wire reconfiguration."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+ if user_input:
+ self._async_abort_entries_match(
+ {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
+ )
+
+ await validate_input(self.hass, user_input, errors)
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry, data_updates=user_input
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ DATA_SCHEMA, reconfigure_entry.data | (user_input or {})
+ ),
+ description_placeholders={"name": reconfigure_entry.title},
+ errors=errors,
+ )
+
+ async def async_step_hassio(
+ self, discovery_info: HassioServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle hassio discovery."""
+ await self._async_handle_discovery_without_unique_id()
+
+ self._discovery_data = {
+ "title": discovery_info.config["addon"],
+ CONF_HOST: discovery_info.config[CONF_HOST],
+ CONF_PORT: discovery_info.config[CONF_PORT],
+ }
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_zeroconf(
+ self, discovery_info: ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle zeroconf discovery."""
+ await self._async_handle_discovery_without_unique_id()
+
+ self._discovery_data = {
+ "title": discovery_info.name,
+ CONF_HOST: discovery_info.hostname,
+ CONF_PORT: discovery_info.port,
+ }
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm discovery."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ data = {
+ CONF_HOST: self._discovery_data[CONF_HOST],
+ CONF_PORT: self._discovery_data[CONF_PORT],
+ }
+ await validate_input(self.hass, data, errors)
+ if not errors:
+ return self.async_create_entry(
+ title=self._discovery_data["title"], data=data
+ )
+
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={"host": self._discovery_data[CONF_HOST]},
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: OneWireConfigEntry,
) -> OnewireOptionsFlowHandler:
"""Get the options flow for this handler."""
return OnewireOptionsFlowHandler(config_entry)
@@ -126,7 +178,7 @@ class OnewireOptionsFlowHandler(OptionsFlow):
current_device: str
"""Friendly name of the currently selected device."""
- def __init__(self, config_entry: ConfigEntry) -> None:
+ def __init__(self, config_entry: OneWireConfigEntry) -> None:
"""Initialize options flow."""
self.options = deepcopy(dict(config_entry.options))
diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py
index a4f3ebe9a78..2ab44c47892 100644
--- a/homeassistant/components/onewire/const.py
+++ b/homeassistant/components/onewire/const.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-from homeassistant.const import Platform
-
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 4304
@@ -54,9 +52,3 @@ MANUFACTURER_EDS = "Embedded Data Systems"
READ_MODE_BOOL = "bool"
READ_MODE_FLOAT = "float"
READ_MODE_INT = "int"
-
-PLATFORMS = [
- Platform.BINARY_SENSOR,
- Platform.SENSOR,
- Platform.SWITCH,
-]
diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py
index 523bb4e2580..48426cf3b5b 100644
--- a/homeassistant/components/onewire/diagnostics.py
+++ b/homeassistant/components/onewire/diagnostics.py
@@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-from . import OneWireConfigEntry
+from .onewirehub import OneWireConfigEntry
TO_REDACT = {CONF_HOST}
diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py
index bbf36deaaa0..2ea21aca488 100644
--- a/homeassistant/components/onewire/entity.py
+++ b/homeassistant/components/onewire/entity.py
@@ -54,6 +54,7 @@ class OneWireEntity(Entity):
"""Return the state attributes of the entity."""
return {
"device_file": self._device_file,
+ # raw_value attribute is deprecated and can be removed in 2025.8
"raw_value": self._value_raw,
}
@@ -84,4 +85,4 @@ class OneWireEntity(Entity):
elif self.entity_description.read_mode == READ_MODE_BOOL:
self._state = int(self._value_raw) == 1
else:
- self._state = round(self._value_raw, 1)
+ self._state = self._value_raw
diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json
index 4f3cb5d04ab..844c4c1afb9 100644
--- a/homeassistant/components/onewire/manifest.json
+++ b/homeassistant/components/onewire/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyownet"],
- "requirements": ["pyownet==0.10.0.post1"]
+ "requirements": ["pyownet==0.10.0.post1"],
+ "zeroconf": ["_owserver._tcp.local."]
}
diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py
index 2dc617ba039..a8d8dd06034 100644
--- a/homeassistant/components/onewire/onewirehub.py
+++ b/homeassistant/components/onewire/onewirehub.py
@@ -2,26 +2,20 @@
from __future__ import annotations
+from datetime import datetime, timedelta
import logging
import os
-from typing import TYPE_CHECKING
from pyownet import protocol
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_IDENTIFIERS,
- ATTR_MANUFACTURER,
- ATTR_MODEL,
- ATTR_NAME,
- ATTR_VIA_DEVICE,
- CONF_HOST,
- CONF_PORT,
-)
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util.signal_type import SignalType
from .const import (
DEVICE_SUPPORT,
@@ -42,8 +36,15 @@ DEVICE_MANUFACTURER = {
"EF": MANUFACTURER_HOBBYBOARDS,
}
+_DEVICE_SCAN_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
+type OneWireConfigEntry = ConfigEntry[OneWireHub]
+
+SIGNAL_NEW_DEVICE_CONNECTED = SignalType["OneWireHub", list[OWDeviceDescription]](
+ f"{DOMAIN}_new_device_connected"
+)
+
def _is_known_device(device_family: str, device_type: str | None) -> bool:
"""Check if device family/type is known to the library."""
@@ -55,116 +56,119 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool:
class OneWireHub:
"""Hub to communicate with server."""
- def __init__(self, hass: HomeAssistant) -> None:
+ owproxy: protocol._Proxy
+ devices: list[OWDeviceDescription]
+
+ def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None:
"""Initialize."""
- self.hass = hass
- self.owproxy: protocol._Proxy | None = None
- self.devices: list[OWDeviceDescription] | None = None
+ self._hass = hass
+ self._config_entry = config_entry
- async def connect(self, host: str, port: int) -> None:
- """Connect to the server."""
- try:
- self.owproxy = await self.hass.async_add_executor_job(
- protocol.proxy, host, port
- )
- except protocol.ConnError as exc:
- raise CannotConnect from exc
+ def _initialize(self) -> None:
+ """Connect to the server, and discover connected devices.
- async def initialize(self, config_entry: ConfigEntry) -> None:
- """Initialize a config entry."""
- host = config_entry.data[CONF_HOST]
- port = config_entry.data[CONF_PORT]
+ Needs to be run in executor.
+ """
+ host = self._config_entry.data[CONF_HOST]
+ port = self._config_entry.data[CONF_PORT]
_LOGGER.debug("Initializing connection to %s:%s", host, port)
- await self.connect(host, port)
- await self.discover_devices()
- if TYPE_CHECKING:
- assert self.devices
- # Register discovered devices on Hub
- device_registry = dr.async_get(self.hass)
- for device in self.devices:
- device_info: DeviceInfo = device.device_info
+ self.owproxy = protocol.proxy(host, port)
+ self.devices = _discover_devices(self.owproxy)
+
+ async def initialize(self) -> None:
+ """Initialize a config entry."""
+ await self._hass.async_add_executor_job(self._initialize)
+ self._populate_device_registry(self.devices)
+
+ @callback
+ def _populate_device_registry(self, devices: list[OWDeviceDescription]) -> None:
+ """Populate the device registry."""
+ device_registry = dr.async_get(self._hass)
+ for device in devices:
device_registry.async_get_or_create(
- config_entry_id=config_entry.entry_id,
- identifiers=device_info[ATTR_IDENTIFIERS],
- manufacturer=device_info[ATTR_MANUFACTURER],
- model=device_info[ATTR_MODEL],
- name=device_info[ATTR_NAME],
- via_device=device_info.get(ATTR_VIA_DEVICE),
+ config_entry_id=self._config_entry.entry_id,
+ **device.device_info,
)
- async def discover_devices(self) -> None:
- """Discover all devices."""
- if self.devices is None:
- self.devices = await self.hass.async_add_executor_job(
- self._discover_devices
+ def schedule_scan_for_new_devices(self) -> None:
+ """Schedule a regular scan of the bus for new devices."""
+ self._config_entry.async_on_unload(
+ async_track_time_interval(
+ self._hass, self._scan_for_new_devices, _DEVICE_SCAN_INTERVAL
+ )
+ )
+
+ async def _scan_for_new_devices(self, _: datetime) -> None:
+ """Scan the bus for new devices."""
+ devices = await self._hass.async_add_executor_job(
+ _discover_devices, self.owproxy
+ )
+ existing_device_ids = [device.id for device in self.devices]
+ new_devices = [
+ device for device in devices if device.id not in existing_device_ids
+ ]
+ if new_devices:
+ self.devices.extend(new_devices)
+ self._populate_device_registry(new_devices)
+ async_dispatcher_send(
+ self._hass, SIGNAL_NEW_DEVICE_CONNECTED, self, new_devices
)
- def _discover_devices(
- self, path: str = "/", parent_id: str | None = None
- ) -> list[OWDeviceDescription]:
- """Discover all server devices."""
- devices: list[OWDeviceDescription] = []
- assert self.owproxy
- for device_path in self.owproxy.dir(path):
- device_id = os.path.split(os.path.split(device_path)[0])[1]
- device_family = self.owproxy.read(f"{device_path}family").decode()
- _LOGGER.debug("read `%sfamily`: %s", device_path, device_family)
- device_type = self._get_device_type(device_path)
- if not _is_known_device(device_family, device_type):
- _LOGGER.warning(
- "Ignoring unknown device family/type (%s/%s) found for device %s",
- device_family,
- device_type,
- device_id,
+
+def _discover_devices(
+ owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None
+) -> list[OWDeviceDescription]:
+ """Discover all server devices."""
+ devices: list[OWDeviceDescription] = []
+ for device_path in owproxy.dir(path):
+ device_id = os.path.split(os.path.split(device_path)[0])[1]
+ device_family = owproxy.read(f"{device_path}family").decode()
+ _LOGGER.debug("read `%sfamily`: %s", device_path, device_family)
+ device_type = _get_device_type(owproxy, device_path)
+ if not _is_known_device(device_family, device_type):
+ _LOGGER.warning(
+ "Ignoring unknown device family/type (%s/%s) found for device %s",
+ device_family,
+ device_type,
+ device_id,
+ )
+ continue
+ device_info = DeviceInfo(
+ identifiers={(DOMAIN, device_id)},
+ manufacturer=DEVICE_MANUFACTURER.get(device_family, MANUFACTURER_MAXIM),
+ model=device_type,
+ model_id=device_type,
+ name=device_id,
+ serial_number=device_id[3:],
+ )
+ if parent_id:
+ device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id)
+ device = OWDeviceDescription(
+ device_info=device_info,
+ id=device_id,
+ family=device_family,
+ path=device_path,
+ type=device_type,
+ )
+ devices.append(device)
+ if device_branches := DEVICE_COUPLERS.get(device_family):
+ for branch in device_branches:
+ devices += _discover_devices(
+ owproxy, f"{device_path}{branch}", device_id
)
- continue
- device_info: DeviceInfo = {
- ATTR_IDENTIFIERS: {(DOMAIN, device_id)},
- ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get(
- device_family, MANUFACTURER_MAXIM
- ),
- ATTR_MODEL: device_type,
- ATTR_NAME: device_id,
- }
- if parent_id:
- device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id)
- device = OWDeviceDescription(
- device_info=device_info,
- id=device_id,
- family=device_family,
- path=device_path,
- type=device_type,
- )
- devices.append(device)
- if device_branches := DEVICE_COUPLERS.get(device_family):
- for branch in device_branches:
- devices += self._discover_devices(
- f"{device_path}{branch}", device_id
- )
- return devices
-
- def _get_device_type(self, device_path: str) -> str | None:
- """Get device model."""
- if TYPE_CHECKING:
- assert self.owproxy
- try:
- device_type = self.owproxy.read(f"{device_path}type").decode()
- except protocol.ProtocolError as exc:
- _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc)
- return None
- _LOGGER.debug("read `%stype`: %s", device_path, device_type)
- if device_type == "EDS":
- device_type = self.owproxy.read(f"{device_path}device_type").decode()
- _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type)
- if TYPE_CHECKING:
- assert isinstance(device_type, str)
- return device_type
+ return devices
-class CannotConnect(HomeAssistantError):
- """Error to indicate we cannot connect."""
-
-
-class InvalidPath(HomeAssistantError):
- """Error to indicate the path is invalid."""
+def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None:
+ """Get device model."""
+ try:
+ device_type: str = owproxy.read(f"{device_path}type").decode()
+ except protocol.ProtocolError as exc:
+ _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc)
+ return None
+ _LOGGER.debug("read `%stype`: %s", device_path, device_type)
+ if device_type == "EDS":
+ device_type = owproxy.read(f"{device_path}device_type").decode()
+ _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type)
+ return device_type
diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml
new file mode 100644
index 00000000000..d46ed69f0d6
--- /dev/null
+++ b/homeassistant/components/onewire/quality_scale.yaml
@@ -0,0 +1,126 @@
+rules:
+ ## Bronze
+ config-flow:
+ status: todo
+ comment: missing data_description on options flow
+ test-before-configure: done
+ unique-config-entry:
+ status: done
+ comment: unique ID is not available, but duplicates are prevented based on host/port
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: entities do not subscribe to events
+ dependency-transparency:
+ status: todo
+ comment: The package is not built and published inside a CI pipeline
+ action-setup:
+ status: exempt
+ comment: No service actions currently available
+ common-modules:
+ status: done
+ comment: base entity available, but no coordinator
+ docs-high-level-description:
+ status: todo
+ comment: Under review
+ docs-installation-instructions:
+ status: todo
+ comment: Under review
+ docs-removal-instructions:
+ status: todo
+ comment: Under review
+ docs-actions:
+ status: todo
+ comment: Under review
+ brands: done
+
+ ## Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: No service actions currently available
+ reauthentication-flow:
+ status: exempt
+ comment: Local polling without authentication
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters:
+ status: todo
+ comment: Under review
+ docs-configuration-parameters:
+ status: todo
+ comment: Under review
+
+ ## Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery:
+ status: done
+ comment: hassio and mDNS/zeroconf discovery implemented
+ stale-devices:
+ status: done
+ comment: >
+ Manual removal, as it is not possible to distinguish
+ between a flaky device and a device that has been removed
+ diagnostics:
+ status: todo
+ comment: config-entry diagnostics level available, might be nice to have device-level diagnostics
+ exception-translations:
+ status: todo
+ comment: Under review
+ icon-translations:
+ status: exempt
+ comment: It doesn't make sense to override defaults
+ reconfiguration-flow: done
+ dynamic-devices:
+ status: done
+ comment: The bus is scanned for new devices at regular interval
+ discovery-update-info:
+ status: todo
+ comment: Under review
+ repair-issues:
+ status: exempt
+ comment: No repairs available
+ docs-use-cases:
+ status: todo
+ comment: Under review
+ docs-supported-devices:
+ status: todo
+ comment: Under review
+ docs-supported-functions:
+ status: todo
+ comment: Under review
+ docs-data-update:
+ status: todo
+ comment: Under review
+ docs-known-limitations:
+ status: todo
+ comment: Under review
+ docs-troubleshooting:
+ status: todo
+ comment: Under review
+ docs-examples:
+ status: todo
+ comment: Under review
+
+ ## Platinum
+ async-dependency:
+ status: todo
+ comment: The dependency is not async
+ inject-websession:
+ status: exempt
+ comment: No websession
+ strict-typing:
+ status: todo
+ comment: The dependency is not typed
diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py
new file mode 100644
index 00000000000..7a26ecdbb31
--- /dev/null
+++ b/homeassistant/components/onewire/select.py
@@ -0,0 +1,110 @@
+"""Support for 1-Wire environment select entities."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import os
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import READ_MODE_INT
+from .entity import OneWireEntity, OneWireEntityDescription
+from .onewirehub import (
+ SIGNAL_NEW_DEVICE_CONNECTED,
+ OneWireConfigEntry,
+ OneWireHub,
+ OWDeviceDescription,
+)
+
+# the library uses non-persistent connections
+# and concurrent access to the bus is managed by the server
+PARALLEL_UPDATES = 0
+SCAN_INTERVAL = timedelta(seconds=30)
+
+
+@dataclass(frozen=True)
+class OneWireSelectEntityDescription(OneWireEntityDescription, SelectEntityDescription):
+ """Class describing OneWire select entities."""
+
+
+ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = {
+ "28": (
+ OneWireSelectEntityDescription(
+ key="tempres",
+ entity_category=EntityCategory.CONFIG,
+ read_mode=READ_MODE_INT,
+ options=["9", "10", "11", "12"],
+ translation_key="tempres",
+ ),
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OneWireConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up 1-Wire platform."""
+
+ async def _add_entities(
+ hub: OneWireHub, devices: list[OWDeviceDescription]
+ ) -> None:
+ """Add 1-Wire entities for all devices."""
+ if not devices:
+ return
+ async_add_entities(get_entities(hub, devices), True)
+
+ hub = config_entry.runtime_data
+ await _add_entities(hub, hub.devices)
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
+ )
+
+
+def get_entities(
+ onewire_hub: OneWireHub, devices: list[OWDeviceDescription]
+) -> list[OneWireSelectEntity]:
+ """Get a list of entities."""
+ entities: list[OneWireSelectEntity] = []
+
+ for device in devices:
+ family = device.family
+ device_id = device.id
+ device_info = device.device_info
+
+ if family not in ENTITY_DESCRIPTIONS:
+ continue
+ for description in ENTITY_DESCRIPTIONS[family]:
+ device_file = os.path.join(os.path.split(device.path)[0], description.key)
+ entities.append(
+ OneWireSelectEntity(
+ description=description,
+ device_id=device_id,
+ device_file=device_file,
+ device_info=device_info,
+ owproxy=onewire_hub.owproxy,
+ )
+ )
+
+ return entities
+
+
+class OneWireSelectEntity(OneWireEntity, SelectEntity):
+ """Implementation of a 1-Wire switch."""
+
+ entity_description: OneWireSelectEntityDescription
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the selected entity option to represent the entity state."""
+ return str(self._state)
+
+ def select_option(self, option: str) -> None:
+ """Change the selected option."""
+ self._write_value(option.encode("ascii"))
diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py
index 2dca53af1cf..1c4047abf0a 100644
--- a/homeassistant/components/onewire/sensor.py
+++ b/homeassistant/components/onewire/sensor.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
import dataclasses
+from datetime import timedelta
import logging
import os
from types import MappingProxyType
@@ -25,10 +26,10 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from . import OneWireConfigEntry
from .const import (
DEVICE_KEYS_0_3,
DEVICE_KEYS_A_B,
@@ -39,7 +40,17 @@ from .const import (
READ_MODE_INT,
)
from .entity import OneWireEntity, OneWireEntityDescription
-from .onewirehub import OneWireHub
+from .onewirehub import (
+ SIGNAL_NEW_DEVICE_CONNECTED,
+ OneWireConfigEntry,
+ OneWireHub,
+ OWDeviceDescription,
+)
+
+# the library uses non-persistent connections
+# and concurrent access to the bus is managed by the server
+PARALLEL_UPDATES = 0
+SCAN_INTERVAL = timedelta(seconds=30)
@dataclasses.dataclass(frozen=True)
@@ -352,22 +363,35 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
- entities = await hass.async_add_executor_job(
- get_entities, config_entry.runtime_data, config_entry.options
+
+ async def _add_entities(
+ hub: OneWireHub, devices: list[OWDeviceDescription]
+ ) -> None:
+ """Add 1-Wire entities for all devices."""
+ if not devices:
+ return
+ # note: we have to go through the executor as SENSOR platform
+ # makes extra calls to the hub during device listing
+ entities = await hass.async_add_executor_job(
+ get_entities, hub, devices, config_entry.options
+ )
+ async_add_entities(entities, True)
+
+ hub = config_entry.runtime_data
+ await _add_entities(hub, hub.devices)
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
- async_add_entities(entities, True)
def get_entities(
- onewire_hub: OneWireHub, options: MappingProxyType[str, Any]
-) -> list[OneWireSensor]:
+ onewire_hub: OneWireHub,
+ devices: list[OWDeviceDescription],
+ options: MappingProxyType[str, Any],
+) -> list[OneWireSensorEntity]:
"""Get a list of entities."""
- if not onewire_hub.devices:
- return []
-
- entities: list[OneWireSensor] = []
- assert onewire_hub.owproxy
- for device in onewire_hub.devices:
+ entities: list[OneWireSensorEntity] = []
+ for device in devices:
family = device.family
device_type = device.type
device_id = device.id
@@ -421,7 +445,7 @@ def get_entities(
)
continue
entities.append(
- OneWireSensor(
+ OneWireSensorEntity(
description=description,
device_id=device_id,
device_file=device_file,
@@ -432,7 +456,7 @@ def get_entities(
return entities
-class OneWireSensor(OneWireEntity, SensorEntity):
+class OneWireSensorEntity(OneWireEntity, SensorEntity):
"""Implementation of a 1-Wire sensor."""
entity_description: OneWireSensorEntityDescription
diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json
index 68585c3203f..8f46369a70b 100644
--- a/homeassistant/components/onewire/strings.json
+++ b/homeassistant/components/onewire/strings.json
@@ -1,21 +1,37 @@
{
"config": {
"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%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
+ "discovery_confirm": {
+ "description": "Do you want to set up OWServer from {host}?"
+ },
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "[%key:component::onewire::config::step::user::data_description::host%]",
+ "port": "[%key:component::onewire::config::step::user::data_description::port%]"
+ },
+ "description": "Update OWServer configuration for {name}"
+ },
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
- "host": "The hostname or IP address of your 1-Wire device."
+ "host": "The hostname or IP address of your OWServer instance.",
+ "port": "The port of your OWServer instance (default is 4304)."
},
- "title": "Set server details"
+ "title": "Set OWServer instance details"
}
}
},
@@ -28,6 +44,17 @@
"name": "Hub short on branch {id}"
}
},
+ "select": {
+ "tempres": {
+ "name": "Temperature resolution",
+ "state": {
+ "9": "9 bits (0.5°C, fastest, up to 93.75ms)",
+ "10": "10 bits (0.25°C, up to 187.5ms)",
+ "11": "11 bits (0.125°C, up to 375ms)",
+ "12": "12 bits (0.0625°C, slowest, up to 750ms)"
+ }
+ }
+ },
"sensor": {
"counter_id": {
"name": "Counter {id}"
diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py
index ec0bc44e03f..7215b1ec020 100644
--- a/homeassistant/components/onewire/switch.py
+++ b/homeassistant/components/onewire/switch.py
@@ -3,18 +3,29 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import timedelta
import os
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import OneWireConfigEntry
from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL
from .entity import OneWireEntity, OneWireEntityDescription
-from .onewirehub import OneWireHub
+from .onewirehub import (
+ SIGNAL_NEW_DEVICE_CONNECTED,
+ OneWireConfigEntry,
+ OneWireHub,
+ OWDeviceDescription,
+)
+
+# the library uses non-persistent connections
+# and concurrent access to the bus is managed by the server
+PARALLEL_UPDATES = 0
+SCAN_INTERVAL = timedelta(seconds=30)
@dataclass(frozen=True)
@@ -153,20 +164,29 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
- entities = await hass.async_add_executor_job(
- get_entities, config_entry.runtime_data
+
+ async def _add_entities(
+ hub: OneWireHub, devices: list[OWDeviceDescription]
+ ) -> None:
+ """Add 1-Wire entities for all devices."""
+ if not devices:
+ return
+ async_add_entities(get_entities(hub, devices), True)
+
+ hub = config_entry.runtime_data
+ await _add_entities(hub, hub.devices)
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
- async_add_entities(entities, True)
-def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]:
+def get_entities(
+ onewire_hub: OneWireHub, devices: list[OWDeviceDescription]
+) -> list[OneWireSwitchEntity]:
"""Get a list of entities."""
- if not onewire_hub.devices:
- return []
+ entities: list[OneWireSwitchEntity] = []
- entities: list[OneWireSwitch] = []
-
- for device in onewire_hub.devices:
+ for device in devices:
family = device.family
device_type = device.type
device_id = device.id
@@ -184,7 +204,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]:
for description in get_sensor_types(device_sub_type)[family]:
device_file = os.path.join(os.path.split(device.path)[0], description.key)
entities.append(
- OneWireSwitch(
+ OneWireSwitchEntity(
description=description,
device_id=device_id,
device_file=device_file,
@@ -196,7 +216,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]:
return entities
-class OneWireSwitch(OneWireEntity, SwitchEntity):
+class OneWireSwitchEntity(OneWireEntity, SwitchEntity):
"""Implementation of a 1-Wire switch."""
entity_description: OneWireSwitchEntityDescription
diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py
index a484b3aaa04..228748d5257 100644
--- a/homeassistant/components/onkyo/config_flow.py
+++ b/homeassistant/components/onkyo/config_flow.py
@@ -6,7 +6,6 @@ from typing import Any
import voluptuous as vol
from yarl import URL
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -16,6 +15,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import callback
+from homeassistant.data_entry_flow import section
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
@@ -26,6 +26,7 @@ from homeassistant.helpers.selector import (
SelectSelectorMode,
TextSelector,
)
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
CONF_RECEIVER_MAX_VOLUME,
@@ -49,9 +50,13 @@ INPUT_SOURCES_ALL_MEANINGS = [
input_source.value_meaning for input_source in InputSource
]
STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
-STEP_CONFIGURE_SCHEMA = vol.Schema(
+STEP_RECONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(OPTION_VOLUME_RESOLUTION): vol.In(VOLUME_RESOLUTION_ALLOWED),
+ }
+)
+STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend(
+ {
vol.Required(OPTION_INPUT_SOURCES): SelectSelector(
SelectSelectorConfig(
options=INPUT_SOURCES_ALL_MEANINGS,
@@ -168,7 +173,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle flow initialized by SSDP discovery."""
_LOGGER.debug("Config flow start ssdp: %s", discovery_info)
@@ -216,55 +221,52 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the configuration of a single receiver."""
errors = {}
- entry = None
- entry_options = None
+ reconfigure_entry = None
+ schema = STEP_CONFIGURE_SCHEMA
if self.source == SOURCE_RECONFIGURE:
- entry = self._get_reconfigure_entry()
- entry_options = entry.options
+ schema = STEP_RECONFIGURE_SCHEMA
+ reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
- source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES]
- if not source_meanings:
+ volume_resolution = user_input[OPTION_VOLUME_RESOLUTION]
+
+ if reconfigure_entry is not None:
+ entry_options = reconfigure_entry.options
+ result = self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data={
+ CONF_HOST: self._receiver_info.host,
+ },
+ options={
+ OPTION_VOLUME_RESOLUTION: volume_resolution,
+ OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME],
+ OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES],
+ },
+ )
+
+ _LOGGER.debug("Reconfigured receiver, result: %s", result)
+ return result
+
+ input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES]
+ if not input_source_meanings:
errors[OPTION_INPUT_SOURCES] = "empty_input_source_list"
else:
- sources_store: dict[str, str] = {}
- for source_meaning in source_meanings:
- source = InputSource.from_meaning(source_meaning)
+ input_sources_store: dict[str, str] = {}
+ for input_source_meaning in input_source_meanings:
+ input_source = InputSource.from_meaning(input_source_meaning)
+ input_sources_store[input_source.value] = input_source_meaning
- source_name = source_meaning
- if entry_options is not None:
- source_name = entry_options[OPTION_INPUT_SOURCES].get(
- source.value, source_name
- )
- sources_store[source.value] = source_name
-
- volume_resolution = user_input[OPTION_VOLUME_RESOLUTION]
-
- if entry_options is None:
- result = self.async_create_entry(
- title=self._receiver_info.model_name,
- data={
- CONF_HOST: self._receiver_info.host,
- },
- options={
- OPTION_VOLUME_RESOLUTION: volume_resolution,
- OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT,
- OPTION_INPUT_SOURCES: sources_store,
- },
- )
- else:
- assert entry is not None
- result = self.async_update_reload_and_abort(
- entry,
- data={
- CONF_HOST: self._receiver_info.host,
- },
- options={
- OPTION_VOLUME_RESOLUTION: volume_resolution,
- OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME],
- OPTION_INPUT_SOURCES: sources_store,
- },
- )
+ result = self.async_create_entry(
+ title=self._receiver_info.model_name,
+ data={
+ CONF_HOST: self._receiver_info.host,
+ },
+ options={
+ OPTION_VOLUME_RESOLUTION: volume_resolution,
+ OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT,
+ OPTION_INPUT_SOURCES: input_sources_store,
+ },
+ )
_LOGGER.debug("Configured receiver, result: %s", result)
return result
@@ -273,12 +275,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
suggested_values = user_input
if suggested_values is None:
- if entry_options is None:
+ if reconfigure_entry is None:
suggested_values = {
OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT,
OPTION_INPUT_SOURCES: [],
}
else:
+ entry_options = reconfigure_entry.options
suggested_values = {
OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION],
OPTION_INPUT_SOURCES: [
@@ -289,9 +292,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="configure_receiver",
- data_schema=self.add_suggested_values_to_schema(
- STEP_CONFIGURE_SCHEMA, suggested_values
- ),
+ data_schema=self.add_suggested_values_to_schema(schema, suggested_values),
errors=errors,
description_placeholders={
"name": f"{self._receiver_info.model_name} ({self._receiver_info.host})"
@@ -360,57 +361,107 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
- def async_get_options_flow(
- config_entry: ConfigEntry,
- ) -> OptionsFlow:
+ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Return the options flow."""
- return OnkyoOptionsFlowHandler(config_entry)
+ return OnkyoOptionsFlowHandler()
+
+
+OPTIONS_STEP_INIT_SCHEMA = vol.Schema(
+ {
+ vol.Required(OPTION_MAX_VOLUME): NumberSelector(
+ NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX)
+ ),
+ vol.Required(OPTION_INPUT_SOURCES): SelectSelector(
+ SelectSelectorConfig(
+ options=INPUT_SOURCES_ALL_MEANINGS,
+ multiple=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ }
+)
class OnkyoOptionsFlowHandler(OptionsFlow):
"""Handle an options flow for Onkyo."""
- def __init__(self, config_entry: ConfigEntry) -> None:
- """Initialize options flow."""
- sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES]
- self._input_sources = {InputSource(k): v for k, v in sources_store.items()}
+ _data: dict[str, Any]
+ _input_sources: dict[InputSource, str]
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
+ errors = {}
+
+ entry_options = self.config_entry.options
+
if user_input is not None:
- sources_store: dict[str, str] = {}
- for source_meaning, source_name in user_input.items():
- if source_meaning in INPUT_SOURCES_ALL_MEANINGS:
- source = InputSource.from_meaning(source_meaning)
- sources_store[source.value] = source_name
+ self._input_sources = {}
+ for input_source_meaning in user_input[OPTION_INPUT_SOURCES]:
+ input_source = InputSource.from_meaning(input_source_meaning)
+ input_source_name = entry_options[OPTION_INPUT_SOURCES].get(
+ input_source.value, input_source_meaning
+ )
+ self._input_sources[input_source] = input_source_name
+
+ if not self._input_sources:
+ errors[OPTION_INPUT_SOURCES] = "empty_input_source_list"
+ else:
+ self._data = {
+ OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION],
+ OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME],
+ }
+
+ return await self.async_step_names()
+
+ suggested_values = user_input
+ if suggested_values is None:
+ suggested_values = {
+ OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME],
+ OPTION_INPUT_SOURCES: [
+ InputSource(input_source).value_meaning
+ for input_source in entry_options[OPTION_INPUT_SOURCES]
+ ],
+ }
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ OPTIONS_STEP_INIT_SCHEMA, suggested_values
+ ),
+ errors=errors,
+ )
+
+ async def async_step_names(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Configure names."""
+ if user_input is not None:
+ input_sources_store: dict[str, str] = {}
+ for input_source_meaning, input_source_name in user_input[
+ "input_sources"
+ ].items():
+ input_source = InputSource.from_meaning(input_source_meaning)
+ input_sources_store[input_source.value] = input_source_name
return self.async_create_entry(
data={
- OPTION_VOLUME_RESOLUTION: self.config_entry.options[
- OPTION_VOLUME_RESOLUTION
- ],
- OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME],
- OPTION_INPUT_SOURCES: sources_store,
+ **self._data,
+ OPTION_INPUT_SOURCES: input_sources_store,
}
)
schema_dict: dict[Any, Selector] = {}
- max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME]
- schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = (
- NumberSelector(
- NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX)
- )
- )
-
- for source, source_name in self._input_sources.items():
- schema_dict[vol.Required(source.value_meaning, default=source_name)] = (
- TextSelector()
- )
+ for input_source, input_source_name in self._input_sources.items():
+ schema_dict[
+ vol.Required(input_source.value_meaning, default=input_source_name)
+ ] = TextSelector()
return self.async_show_form(
- step_id="init",
- data_schema=vol.Schema(schema_dict),
+ step_id="names",
+ data_schema=vol.Schema(
+ {vol.Required("input_sources"): section(vol.Schema(schema_dict))}
+ ),
)
diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json
index 849171c7161..b3b14efec44 100644
--- a/homeassistant/components/onkyo/strings.json
+++ b/homeassistant/components/onkyo/strings.json
@@ -27,17 +27,17 @@
"description": "Configure {name}",
"data": {
"volume_resolution": "Volume resolution",
- "input_sources": "Input sources"
+ "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]"
},
"data_description": {
"volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.",
- "input_sources": "List of input sources supported by the receiver."
+ "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "empty_input_source_list": "Input source list cannot be empty",
+ "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
@@ -52,12 +52,25 @@
"step": {
"init": {
"data": {
- "max_volume": "Maximum volume limit (%)"
+ "max_volume": "Maximum volume limit (%)",
+ "input_sources": "Input sources"
},
"data_description": {
- "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value."
+ "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.",
+ "input_sources": "List of input sources supported by the receiver."
+ }
+ },
+ "names": {
+ "sections": {
+ "input_sources": {
+ "name": "Input source names",
+ "description": "Mappings of receiver's input sources to their names."
+ }
}
}
+ },
+ "error": {
+ "empty_input_source_list": "Input source list cannot be empty"
}
},
"issues": {
diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py
index 66e566af0bf..f645444f9c6 100644
--- a/homeassistant/components/onvif/config_flow.py
+++ b/homeassistant/components/onvif/config_flow.py
@@ -11,11 +11,11 @@ from urllib.parse import urlparse
from onvif.util import is_auth_error, stringify_onvif_error
import voluptuous as vol
from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery
+from wsdiscovery.qname import QName
from wsdiscovery.scope import Scope
from wsdiscovery.service import Service
from zeep.exceptions import Fault
-from homeassistant.components import dhcp
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
@@ -39,6 +39,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_DEVICE_ID,
@@ -58,16 +59,22 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device"
def wsdiscovery() -> list[Service]:
"""Get ONVIF Profile S devices from network."""
- discovery = WSDiscovery(ttl=4)
+ discovery = WSDiscovery(ttl=4, relates_to=True)
try:
discovery.start()
return discovery.searchServices(
- scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")]
+ types=[
+ QName(
+ "http://www.onvif.org/ver10/network/wsdl",
+ "NetworkVideoTransmitter",
+ "dp0",
+ )
+ ],
+ scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")],
+ timeout=10,
)
finally:
discovery.stop()
- # Stop the threads started by WSDiscovery since otherwise there is a leak.
- discovery._stopThreads() # noqa: SLF001
async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]:
@@ -170,7 +177,7 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
hass = self.hass
diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py
index f15f6637ab9..6d1a340fc7b 100644
--- a/homeassistant/components/onvif/device.py
+++ b/homeassistant/components/onvif/device.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
ABSOLUTE_MOVE,
diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py
index 4b5335f1eb6..b7b34f7be9f 100644
--- a/homeassistant/components/onvif/event.py
+++ b/homeassistant/components/onvif/event.py
@@ -252,9 +252,9 @@ class PullPointManager:
async def async_start(self) -> bool:
"""Start pullpoint subscription."""
- assert (
- self.state == PullPointManagerState.STOPPED
- ), "PullPoint manager already started"
+ assert self.state == PullPointManagerState.STOPPED, (
+ "PullPoint manager already started"
+ )
LOGGER.debug("%s: Starting PullPoint manager", self._name)
if not await self._async_start_pullpoint():
self.state = PullPointManagerState.FAILED
@@ -501,9 +501,9 @@ class WebHookManager:
async def async_start(self) -> bool:
"""Start polling events."""
LOGGER.debug("%s: Starting webhook manager", self._name)
- assert (
- self.state == WebHookManagerState.STOPPED
- ), "Webhook manager already started"
+ assert self.state == WebHookManagerState.STOPPED, (
+ "Webhook manager already started"
+ )
assert self._webhook_url is None, "Webhook already registered"
self._async_register_webhook()
if not await self._async_start_webhook():
diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json
index 6aa005ba539..78df5130aed 100644
--- a/homeassistant/components/onvif/manifest.json
+++ b/homeassistant/components/onvif/manifest.json
@@ -1,12 +1,12 @@
{
"domain": "onvif",
"name": "ONVIF",
- "codeowners": ["@hunterjm"],
+ "codeowners": ["@hunterjm", "@jterrace"],
"config_flow": true,
"dependencies": ["ffmpeg"],
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
- "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"]
+ "requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.1.2"]
}
diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py
index d7bbaa4fb3f..6eb1d001796 100644
--- a/homeassistant/components/onvif/parsers.py
+++ b/homeassistant/components/onvif/parsers.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
+import dataclasses
import datetime
from typing import Any
@@ -370,22 +371,59 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None:
return None
-@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent")
+_TAPO_EVENT_TEMPLATES: dict[str, Event] = {
+ "IsVehicle": Event(
+ uid="",
+ name="Vehicle Detection",
+ platform="binary_sensor",
+ device_class="motion",
+ ),
+ "IsPeople": Event(
+ uid="", name="Person Detection", platform="binary_sensor", device_class="motion"
+ ),
+ "IsPet": Event(
+ uid="", name="Pet Detection", platform="binary_sensor", device_class="motion"
+ ),
+ "IsLineCross": Event(
+ uid="",
+ name="Line Detector Crossed",
+ platform="binary_sensor",
+ device_class="motion",
+ ),
+ "IsTamper": Event(
+ uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper"
+ ),
+ "IsIntrusion": Event(
+ uid="",
+ name="Intrusion Detection",
+ platform="binary_sensor",
+ device_class="safety",
+ ),
+}
+
+
+@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion")
+@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross")
+@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People")
+@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper")
+@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent")
@PARSERS.register("tns1:RuleEngine/PeopleDetector/People")
+@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent")
async def async_parse_tplink_detector(uid: str, msg) -> Event | None:
"""Handle parsing tplink smart event messages.
- Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
+ Topic: tns1:RuleEngine/CellMotionDetector/Intrusion
+ Topic: tns1:RuleEngine/CellMotionDetector/LineCross
+ Topic: tns1:RuleEngine/CellMotionDetector/People
+ Topic: tns1:RuleEngine/CellMotionDetector/Tamper
+ Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent
Topic: tns1:RuleEngine/PeopleDetector/People
+ Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
"""
- video_source = ""
- video_analytics = ""
- rule = ""
- topic = ""
- vehicle = False
- person = False
- enabled = False
try:
+ video_source = ""
+ video_analytics = ""
+ rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
@@ -396,34 +434,19 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None:
rule = source.Value
for item in payload.Data.SimpleItem:
- if item.Name == "IsVehicle":
- vehicle = True
- enabled = item.Value == "true"
- if item.Name == "IsPeople":
- person = True
- enabled = item.Value == "true"
+ event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None)
+ if event_template is None:
+ continue
+
+ return dataclasses.replace(
+ event_template,
+ uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ value=item.Value == "true",
+ )
+
except (AttributeError, KeyError):
return None
- if vehicle:
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Vehicle Detection",
- "binary_sensor",
- "motion",
- None,
- enabled,
- )
- if person:
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Person Detection",
- "binary_sensor",
- "motion",
- None,
- enabled,
- )
-
return None
diff --git a/homeassistant/components/open_meteo/__init__.py b/homeassistant/components/open_meteo/__init__.py
index 6deb63904ff..34495d4bd0b 100644
--- a/homeassistant/components/open_meteo/__init__.py
+++ b/homeassistant/components/open_meteo/__init__.py
@@ -2,82 +2,27 @@
from __future__ import annotations
-from open_meteo import (
- DailyParameters,
- Forecast,
- HourlyParameters,
- OpenMeteo,
- OpenMeteoError,
- PrecipitationUnit,
- TemperatureUnit,
- WindSpeedUnit,
-)
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DOMAIN, LOGGER, SCAN_INTERVAL
+from .coordinator import OpenMeteoConfigEntry, OpenMeteoDataUpdateCoordinator
PLATFORMS = [Platform.WEATHER]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: OpenMeteoConfigEntry) -> bool:
"""Set up Open-Meteo from a config entry."""
- session = async_get_clientsession(hass)
- open_meteo = OpenMeteo(session=session)
- async def async_update_forecast() -> Forecast:
- if (zone := hass.states.get(entry.data[CONF_ZONE])) is None:
- raise UpdateFailed(f"Zone '{entry.data[CONF_ZONE]}' not found")
-
- try:
- return await open_meteo.forecast(
- latitude=zone.attributes[ATTR_LATITUDE],
- longitude=zone.attributes[ATTR_LONGITUDE],
- current_weather=True,
- daily=[
- DailyParameters.PRECIPITATION_SUM,
- DailyParameters.TEMPERATURE_2M_MAX,
- DailyParameters.TEMPERATURE_2M_MIN,
- DailyParameters.WEATHER_CODE,
- DailyParameters.WIND_DIRECTION_10M_DOMINANT,
- DailyParameters.WIND_SPEED_10M_MAX,
- ],
- hourly=[
- HourlyParameters.PRECIPITATION,
- HourlyParameters.TEMPERATURE_2M,
- HourlyParameters.WEATHER_CODE,
- ],
- precipitation_unit=PrecipitationUnit.MILLIMETERS,
- temperature_unit=TemperatureUnit.CELSIUS,
- timezone="UTC",
- wind_speed_unit=WindSpeedUnit.KILOMETERS_PER_HOUR,
- )
- except OpenMeteoError as err:
- raise UpdateFailed("Open-Meteo API communication error") from err
-
- coordinator: DataUpdateCoordinator[Forecast] = DataUpdateCoordinator(
- hass,
- LOGGER,
- config_entry=entry,
- name=f"{DOMAIN}_{entry.data[CONF_ZONE]}",
- update_interval=SCAN_INTERVAL,
- update_method=async_update_forecast,
- )
+ coordinator = OpenMeteoDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: OpenMeteoConfigEntry) -> bool:
"""Unload Open-Meteo config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py
new file mode 100644
index 00000000000..9e2f262db78
--- /dev/null
+++ b/homeassistant/components/open_meteo/coordinator.py
@@ -0,0 +1,73 @@
+"""DataUpdateCoordinator for the Open-Meteo integration."""
+
+from __future__ import annotations
+
+from open_meteo import (
+ DailyParameters,
+ Forecast,
+ HourlyParameters,
+ OpenMeteo,
+ OpenMeteoError,
+ PrecipitationUnit,
+ TemperatureUnit,
+ WindSpeedUnit,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, LOGGER, SCAN_INTERVAL
+
+type OpenMeteoConfigEntry = ConfigEntry[OpenMeteoDataUpdateCoordinator]
+
+
+class OpenMeteoDataUpdateCoordinator(DataUpdateCoordinator[Forecast]):
+ """A Open-Meteo Data Update Coordinator."""
+
+ config_entry: OpenMeteoConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: OpenMeteoConfigEntry) -> None:
+ """Initialize the Open-Meteo coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=f"{DOMAIN}_{config_entry.data[CONF_ZONE]}",
+ update_interval=SCAN_INTERVAL,
+ )
+ session = async_get_clientsession(hass)
+ self.open_meteo = OpenMeteo(session=session)
+
+ async def _async_update_data(self) -> Forecast:
+ """Fetch data from Sensibo."""
+ if (zone := self.hass.states.get(self.config_entry.data[CONF_ZONE])) is None:
+ raise UpdateFailed(f"Zone '{self.config_entry.data[CONF_ZONE]}' not found")
+
+ try:
+ return await self.open_meteo.forecast(
+ latitude=zone.attributes[ATTR_LATITUDE],
+ longitude=zone.attributes[ATTR_LONGITUDE],
+ current_weather=True,
+ daily=[
+ DailyParameters.PRECIPITATION_SUM,
+ DailyParameters.TEMPERATURE_2M_MAX,
+ DailyParameters.TEMPERATURE_2M_MIN,
+ DailyParameters.WEATHER_CODE,
+ DailyParameters.WIND_DIRECTION_10M_DOMINANT,
+ DailyParameters.WIND_SPEED_10M_MAX,
+ ],
+ hourly=[
+ HourlyParameters.PRECIPITATION,
+ HourlyParameters.TEMPERATURE_2M,
+ HourlyParameters.WEATHER_CODE,
+ ],
+ precipitation_unit=PrecipitationUnit.MILLIMETERS,
+ temperature_unit=TemperatureUnit.CELSIUS,
+ timezone="UTC",
+ wind_speed_unit=WindSpeedUnit.KILOMETERS_PER_HOUR,
+ )
+ except OpenMeteoError as err:
+ raise UpdateFailed("Open-Meteo API communication error") from err
diff --git a/homeassistant/components/open_meteo/diagnostics.py b/homeassistant/components/open_meteo/diagnostics.py
index 0ce9f4fcf3d..44bf7d60e24 100644
--- a/homeassistant/components/open_meteo/diagnostics.py
+++ b/homeassistant/components/open_meteo/diagnostics.py
@@ -4,15 +4,11 @@ from __future__ import annotations
from typing import Any
-from open_meteo import Forecast
-
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN
+from .coordinator import OpenMeteoConfigEntry
TO_REDACT = {
CONF_LATITUDE,
@@ -21,8 +17,8 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: OpenMeteoConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: DataUpdateCoordinator[Forecast] = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
return async_redact_data(coordinator.data.to_dict(), TO_REDACT)
diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py
index 1faa66c56de..51ee91de083 100644
--- a/homeassistant/components/open_meteo/weather.py
+++ b/homeassistant/components/open_meteo/weather.py
@@ -17,7 +17,6 @@ from homeassistant.components.weather import (
SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -26,15 +25,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import DOMAIN, WMO_TO_HA_CONDITION_MAP
+from .coordinator import OpenMeteoConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OpenMeteoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Open-Meteo weather entity based on a config entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities([OpenMeteoWeatherEntity(entry=entry, coordinator=coordinator)])
@@ -55,7 +55,7 @@ class OpenMeteoWeatherEntity(
def __init__(
self,
*,
- entry: ConfigEntry,
+ entry: OpenMeteoConfigEntry,
coordinator: DataUpdateCoordinator[OpenMeteoForecast],
) -> None:
"""Initialize Open-Meteo weather entity."""
diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py
index 9c73766c8d4..2f35bea97e2 100644
--- a/homeassistant/components/openai_conversation/conversation.py
+++ b/homeassistant/components/openai_conversation/conversation.py
@@ -2,7 +2,7 @@
from collections.abc import Callable
import json
-from typing import Any, Literal
+from typing import Any, Literal, cast
import openai
from openai._types import NOT_GIVEN
@@ -11,25 +11,20 @@ from openai.types.chat import (
ChatCompletionMessage,
ChatCompletionMessageParam,
ChatCompletionMessageToolCallParam,
- ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionToolParam,
- ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition
-import voluptuous as vol
from voluptuous_openapi import convert
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
-from homeassistant.helpers import device_registry as dr, intent, llm, template
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import ulid
from . import OpenAIConfigEntry
from .const import (
@@ -73,6 +68,44 @@ def _format_tool(
return ChatCompletionToolParam(type="function", function=tool_spec)
+def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessageParam:
+ """Convert from class to TypedDict."""
+ tool_calls: list[ChatCompletionMessageToolCallParam] = []
+ if message.tool_calls:
+ tool_calls = [
+ ChatCompletionMessageToolCallParam(
+ id=tool_call.id,
+ function=Function(
+ arguments=tool_call.function.arguments,
+ name=tool_call.function.name,
+ ),
+ type=tool_call.type,
+ )
+ for tool_call in message.tool_calls
+ ]
+ param = ChatCompletionAssistantMessageParam(
+ role=message.role,
+ content=message.content,
+ )
+ if tool_calls:
+ param["tool_calls"] = tool_calls
+ return param
+
+
+def _chat_message_convert(
+ message: conversation.Content
+ | conversation.NativeContent[ChatCompletionMessageParam],
+) -> ChatCompletionMessageParam:
+ """Convert any native chat message for this agent to the native format."""
+ if message.role == "native":
+ # mypy doesn't understand that checking role ensures content type
+ return message.content # type: ignore[return-value]
+ return cast(
+ ChatCompletionMessageParam,
+ {"role": message.role, "content": message.content},
+ )
+
+
class OpenAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
):
@@ -84,7 +117,6 @@ class OpenAIConversationEntity(
def __init__(self, entry: OpenAIConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
- self.history: dict[str, list[ChatCompletionMessageParam]] = {}
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -123,116 +155,41 @@ class OpenAIConversationEntity(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
+ async with conversation.async_get_chat_session(
+ self.hass, user_input
+ ) as session:
+ return await self._async_handle_message(user_input, session)
+
+ async def _async_handle_message(
+ self,
+ user_input: conversation.ConversationInput,
+ session: conversation.ChatSession[ChatCompletionMessageParam],
+ ) -> conversation.ConversationResult:
+ """Call the API."""
+ assert user_input.agent_id
options = self.entry.options
- intent_response = intent.IntentResponse(language=user_input.language)
- llm_api: llm.APIInstance | None = None
- tools: list[ChatCompletionToolParam] | 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,
- "Error preparing LLM API",
- )
- 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,
- )
+ await session.async_update_llm_data(
+ DOMAIN,
+ user_input,
+ options.get(CONF_LLM_HASS_API),
+ options.get(CONF_PROMPT),
+ )
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+
+ tools: list[ChatCompletionToolParam] | None = None
+ if session.llm_api:
+ tools = [
+ _format_tool(tool, session.llm_api.custom_serializer)
+ for tool in session.llm_api.tools
]
- except TemplateError as err:
- LOGGER.error("Error rendering prompt: %s", err)
- intent_response = intent.IntentResponse(language=user_input.language)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- "Sorry, I had a problem with my template",
- )
- 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 = [
- ChatCompletionSystemMessageParam(role="system", content=prompt),
- *messages[1:],
- ChatCompletionUserMessageParam(role="user", content=user_input.text),
+ _chat_message_convert(message) for message in session.async_get_messages()
]
- LOGGER.debug("Prompt: %s", messages)
- LOGGER.debug("Tools: %s", tools)
- trace.async_conversation_trace_append(
- trace.ConversationTraceEventType.AGENT_DETAIL,
- {"messages": messages, "tools": llm_api.tools if llm_api else None},
- )
-
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations
@@ -245,70 +202,33 @@ class OpenAIConversationEntity(
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
- user=conversation_id,
+ user=session.conversation_id,
)
except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err)
- intent_response = intent.IntentResponse(language=user_input.language)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- "Sorry, I had a problem talking to OpenAI",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
- )
+ raise HomeAssistantError("Error talking to OpenAI") from err
LOGGER.debug("Response %s", result)
response = result.choices[0].message
+ messages.append(_message_convert(response))
- def message_convert(
- message: ChatCompletionMessage,
- ) -> ChatCompletionMessageParam:
- """Convert from class to TypedDict."""
- tool_calls: list[ChatCompletionMessageToolCallParam] = []
- if message.tool_calls:
- tool_calls = [
- ChatCompletionMessageToolCallParam(
- id=tool_call.id,
- function=Function(
- arguments=tool_call.function.arguments,
- name=tool_call.function.name,
- ),
- type=tool_call.type,
- )
- for tool_call in message.tool_calls
- ]
- param = ChatCompletionAssistantMessageParam(
- role=message.role,
- content=message.content,
- )
- if tool_calls:
- param["tool_calls"] = tool_calls
- return param
+ session.async_add_message(
+ conversation.Content(
+ role=response.role,
+ agent_id=user_input.agent_id,
+ content=response.content or "",
+ ),
+ )
- messages.append(message_convert(response))
- tool_calls = response.tool_calls
-
- if not tool_calls or not llm_api:
+ if not response.tool_calls or not session.llm_api:
break
- for tool_call in tool_calls:
+ for tool_call in response.tool_calls:
tool_input = llm.ToolInput(
tool_name=tool_call.function.name,
tool_args=json.loads(tool_call.function.arguments),
)
- 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_response = await session.async_call_tool(tool_input)
messages.append(
ChatCompletionToolMessageParam(
role="tool",
@@ -316,13 +236,17 @@ class OpenAIConversationEntity(
content=json.dumps(tool_response),
)
)
-
- self.history[conversation_id] = messages
+ session.async_add_message(
+ conversation.NativeContent(
+ agent_id=user_input.agent_id,
+ content=messages[-1],
+ )
+ )
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(response.content or "")
return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
+ response=intent_response, conversation_id=session.conversation_id
)
async def _async_entry_update_listener(
diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json
index fcbdc996ce5..9b70246117c 100644
--- a/homeassistant/components/openai_conversation/manifest.json
+++ b/homeassistant/components/openai_conversation/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["openai==1.35.7"]
+ "requirements": ["openai==1.59.9"]
}
diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py
index e8a8d6859c1..2bdf9947fe2 100644
--- a/homeassistant/components/openalpr_cloud/image_processing.py
+++ b/homeassistant/components/openalpr_cloud/image_processing.py
@@ -26,8 +26,8 @@ from homeassistant.const import (
CONF_SOURCE,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.async_ import run_callback_threadsafe
diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py
index c228b6c1a14..de86e3d581f 100644
--- a/homeassistant/components/openevse/sensor.py
+++ b/homeassistant/components/openevse/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py
index 30801a59436..4aa334da3a7 100644
--- a/homeassistant/components/openhardwaremonitor/sensor.py
+++ b/homeassistant/components/openhardwaremonitor/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py
index b495819211b..9cd6a79f012 100644
--- a/homeassistant/components/openhome/config_flow.py
+++ b/homeassistant/components/openhome/config_flow.py
@@ -3,13 +3,13 @@
import logging
from typing import Any
-from homeassistant.components.ssdp import (
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_FRIENDLY_NAME,
ATTR_UPNP_UDN,
SsdpServiceInfo,
)
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_NAME
from .const import DOMAIN
diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py
index c9143c977ce..8c903c90bbb 100644
--- a/homeassistant/components/openhome/media_player.py
+++ b/homeassistant/components/openhome/media_player.py
@@ -67,11 +67,9 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
]
-def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> (
- Callable[
- [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R]
- ]
-):
+def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> Callable[
+ [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R]
+]:
"""Catch TimeoutError, aiohttp.ClientError, UpnpError errors."""
def call_wrapper(
diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py
index eb8435751c0..19d19f19a54 100644
--- a/homeassistant/components/opensensemap/air_quality.py
+++ b/homeassistant/components/opensensemap/air_quality.py
@@ -16,8 +16,8 @@ from homeassistant.components.air_quality import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py
index 867a4781265..5e53a805753 100644
--- a/homeassistant/components/opensky/config_flow.py
+++ b/homeassistant/components/opensky/config_flow.py
@@ -23,8 +23,8 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import (
CONF_ALTITUDE,
diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py
index 80c16ee88e1..bcbf279f3f7 100644
--- a/homeassistant/components/opentherm_gw/config_flow.py
+++ b/homeassistant/components/opentherm_gw/config_flow.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
PRECISION_WHOLE,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from . import DOMAIN
from .const import (
diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json
index 4c452da41ae..405af126c03 100644
--- a/homeassistant/components/opentherm_gw/strings.json
+++ b/homeassistant/components/opentherm_gw/strings.json
@@ -15,7 +15,7 @@
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "id_exists": "Gateway id already exists",
+ "id_exists": "Gateway ID already exists",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
}
@@ -379,13 +379,13 @@
"fields": {
"gateway_id": {
"name": "Gateway ID",
- "description": "The gateway_id of the OpenTherm Gateway."
+ "description": "The ID of the OpenTherm Gateway."
}
}
},
"set_central_heating_ovrd": {
"name": "Set central heating override",
- "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.\n.",
+ "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.",
"fields": {
"gateway_id": {
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
@@ -417,7 +417,7 @@
},
"set_control_setpoint": {
"name": "Set control set point",
- "description": "Sets the central heating control setpoint override on the gateway. You will only need this if you are writing your own software thermostat.\n.",
+ "description": "Sets the central heating control setpoint override on the gateway. You will only need this if you are writing your own software thermostat.",
"fields": {
"gateway_id": {
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
@@ -425,7 +425,7 @@
},
"temperature": {
"name": "Temperature",
- "description": "The central heating setpoint to set on the gateway. Values between 0 and 90 are accepted, but not all boilers support this range. A value of 0 disables the central heating setpoint override.\n."
+ "description": "The central heating setpoint to set on the gateway. Values between 0 and 90 are accepted, but not all boilers support this range. A value of 0 disables the central heating setpoint override."
}
}
},
@@ -439,7 +439,7 @@
},
"dhw_override": {
"name": "Domestic hot water override",
- "description": "Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this command can influence that. Value should be 0 or 1 to enable the override in off or on state, or \"A\" to disable the override.\n."
+ "description": "Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this command can influence that. Value should be 0 or 1 to enable the override in off or on state, or \"A\" to disable the override."
}
}
},
@@ -453,7 +453,7 @@
},
"temperature": {
"name": "Temperature",
- "description": "The domestic hot water setpoint to set on the gateway. Not all boilers support this feature. Values between 0 and 90 are accepted, but not all boilers support this range. Check the values of the slave_dhw_min_setp and slave_dhw_max_setp sensors to see the supported range on your boiler.\n."
+ "description": "The domestic hot water setpoint to set on the gateway. Not all boilers support this feature. Values between 0 and 90 are accepted, but not all boilers support this range. Check the values of the slave_dhw_min_setp and slave_dhw_max_setp sensors to see the supported range on your boiler."
}
}
},
@@ -471,7 +471,7 @@
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
- "description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values.\n."
+ "description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values."
}
}
},
@@ -489,13 +489,13 @@
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
- "description": "The function to assign to the LED. See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values.\n."
+ "description": "The function to assign to the LED. See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values."
}
}
},
"set_max_modulation": {
"name": "Set max modulation",
- "description": "Overrides the maximum relative modulation level. You will only need this if you are writing your own software thermostat.\n.",
+ "description": "Overrides the maximum relative modulation level. You will only need this if you are writing your own software thermostat.",
"fields": {
"gateway_id": {
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
@@ -503,13 +503,13 @@
},
"level": {
"name": "Level",
- "description": "The modulation level to provide to the gateway. Provide a value of -1 to clear the override and forward the value from the thermostat again.\n."
+ "description": "The modulation level to provide to the gateway. Provide a value of -1 to clear the override and forward the value from the thermostat again."
}
}
},
"set_outside_temperature": {
"name": "Set outside temperature",
- "description": "Provides an outside temperature to the thermostat. If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect.\n.",
+ "description": "Provides an outside temperature to the thermostat. If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect.",
"fields": {
"gateway_id": {
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
@@ -517,7 +517,7 @@
},
"temperature": {
"name": "Temperature",
- "description": "The temperature to provide to the thermostat. Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range. Any value above 64.0 will clear a previously configured value (suggestion: 99)\n."
+ "description": "The temperature to provide to the thermostat. Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range. Any value above 64.0 will clear a previously configured value (suggestion: 99)."
}
}
},
diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py
index 8d33e117287..4c66778119e 100644
--- a/homeassistant/components/openweathermap/config_flow.py
+++ b/homeassistant/components/openweathermap/config_flow.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
CONF_NAME,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONFIG_FLOW_VERSION,
diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py
index d2ee2e2dfbd..66f35a51b87 100644
--- a/homeassistant/components/opnsense/__init__.py
+++ b/homeassistant/components/opnsense/__init__.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py
index 1a34d0547aa..136a1a4e57a 100644
--- a/homeassistant/components/opower/__init__.py
+++ b/homeassistant/components/opower/__init__.py
@@ -6,27 +6,26 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
from .coordinator import OpowerCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type OpowerConfigEntry = ConfigEntry[OpowerCoordinator]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool:
"""Set up Opower from a config entry."""
coordinator = OpowerCoordinator(hass, entry.data)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py
index 629dce0823c..6957ae4984c 100644
--- a/homeassistant/components/opower/coordinator.py
+++ b/homeassistant/components/opower/coordinator.py
@@ -10,11 +10,11 @@ from opower import (
AggregateType,
CostRead,
Forecast,
- InvalidAuth,
MeterType,
Opower,
ReadResolution,
)
+from opower.exceptions import ApiException, CannotConnect, InvalidAuth
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
@@ -27,7 +27,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, Unit
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
@@ -80,8 +80,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
# assume previous session has expired and re-login.
await self.api.async_login()
except InvalidAuth as err:
+ _LOGGER.error("Error during login: %s", err)
raise ConfigEntryAuthFailed from err
- forecasts: list[Forecast] = await self.api.async_get_forecast()
+ except CannotConnect as err:
+ _LOGGER.error("Error during login: %s", err)
+ raise UpdateFailed(f"Error during login: {err}") from err
+ try:
+ forecasts: list[Forecast] = await self.api.async_get_forecast()
+ except ApiException as err:
+ _LOGGER.error("Error getting forecasts: %s", err)
+ raise
_LOGGER.debug("Updating sensor data with: %s", forecasts)
# Because Opower provides historical usage/cost with a delay of a couple of days
# we need to insert data into statistics.
@@ -90,7 +98,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
async def _insert_statistics(self) -> None:
"""Insert Opower statistics."""
- for account in await self.api.async_get_accounts():
+ try:
+ accounts = await self.api.async_get_accounts()
+ except ApiException as err:
+ _LOGGER.error("Error getting accounts: %s", err)
+ raise
+ for account in accounts:
id_prefix = "_".join(
(
self.api.utility.subdomain(),
@@ -252,9 +265,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
start = datetime.fromtimestamp(start_time, tz=tz) - timedelta(days=30)
end = dt_util.now(tz)
_LOGGER.debug("Getting monthly cost reads: %s - %s", start, end)
- cost_reads = await self.api.async_get_cost_reads(
- account, AggregateType.BILL, start, end
- )
+ try:
+ cost_reads = await self.api.async_get_cost_reads(
+ account, AggregateType.BILL, start, end
+ )
+ except ApiException as err:
+ _LOGGER.error("Error getting monthly cost reads: %s", err)
+ raise
_LOGGER.debug("Got %s monthly cost reads", len(cost_reads))
if account.read_resolution == ReadResolution.BILLING:
return cost_reads
@@ -267,9 +284,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
assert start
start = max(start, end - timedelta(days=3 * 365))
_LOGGER.debug("Getting daily cost reads: %s - %s", start, end)
- daily_cost_reads = await self.api.async_get_cost_reads(
- account, AggregateType.DAY, start, end
- )
+ try:
+ daily_cost_reads = await self.api.async_get_cost_reads(
+ account, AggregateType.DAY, start, end
+ )
+ except ApiException as err:
+ _LOGGER.error("Error getting daily cost reads: %s", err)
+ raise
_LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads))
_update_with_finer_cost_reads(cost_reads, daily_cost_reads)
if account.read_resolution == ReadResolution.DAY:
@@ -281,9 +302,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
assert start
start = max(start, end - timedelta(days=2 * 30))
_LOGGER.debug("Getting hourly cost reads: %s - %s", start, end)
- hourly_cost_reads = await self.api.async_get_cost_reads(
- account, AggregateType.HOUR, start, end
- )
+ try:
+ hourly_cost_reads = await self.api.async_get_cost_reads(
+ account, AggregateType.HOUR, start, end
+ )
+ except ApiException as err:
+ _LOGGER.error("Error getting hourly cost reads: %s", err)
+ raise
_LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads))
_update_with_finer_cost_reads(cost_reads, hourly_cost_reads)
_LOGGER.debug("Got %s cost reads", len(cost_reads))
diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json
index bd68cc84d13..d168cba5752 100644
--- a/homeassistant/components/opower/manifest.json
+++ b/homeassistant/components/opower/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
- "requirements": ["opower==0.8.7"]
+ "requirements": ["opower==0.8.9"]
}
diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py
index 05a22dfbf1b..f9d0fe62332 100644
--- a/homeassistant/components/opower/sensor.py
+++ b/homeassistant/components/opower/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -21,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from . import OpowerConfigEntry
from .const import DOMAIN
from .coordinator import OpowerCoordinator
@@ -97,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: data.start_date,
+ value_fn=lambda data: str(data.start_date),
),
OpowerEntityDescription(
key="elec_end_date",
@@ -105,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: data.end_date,
+ value_fn=lambda data: str(data.end_date),
),
)
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
@@ -169,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: data.start_date,
+ value_fn=lambda data: str(data.start_date),
),
OpowerEntityDescription(
key="gas_end_date",
@@ -177,17 +177,19 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: data.end_date,
+ value_fn=lambda data: str(data.end_date),
),
)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: OpowerConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Opower sensor."""
- coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
entities: list[OpowerSensor] = []
forecasts = coordinator.data.values()
for forecast in forecasts:
diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py
index da2993d1996..e804f06faa3 100644
--- a/homeassistant/components/opple/light.py
+++ b/homeassistant/components/opple/light.py
@@ -17,7 +17,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/oralb/config_flow.py b/homeassistant/components/oralb/config_flow.py
index ab5d919194e..bac2d32bb2f 100644
--- a/homeassistant/components/oralb/config_flow.py
+++ b/homeassistant/components/oralb/config_flow.py
@@ -72,7 +72,7 @@ class OralBConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py
index 213350db6a4..450c56ae50e 100644
--- a/homeassistant/components/oru/sensor.py
+++ b/homeassistant/components/oru/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py
index 2f990333cf6..211abc838e7 100644
--- a/homeassistant/components/orvibo/switch.py
+++ b/homeassistant/components/orvibo/switch.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_SWITCHES,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json
index b8f95c021fa..ca23265048f 100644
--- a/homeassistant/components/osoenergy/strings.json
+++ b/homeassistant/components/osoenergy/strings.json
@@ -2,15 +2,15 @@
"config": {
"step": {
"user": {
- "title": "OSO Energy Auth",
- "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'",
+ "title": "OSO Energy auth",
+ "description": "Enter the 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"reauth": {
- "title": "OSO Energy Auth",
- "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.",
+ "title": "OSO Energy auth",
+ "description": "Enter a new 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
@@ -95,11 +95,11 @@
"services": {
"get_profile": {
"name": "Get heater profile",
- "description": "Get the temperature profile of water heater"
+ "description": "Gets the temperature profile for water heater"
},
"set_profile": {
"name": "Set heater profile",
- "description": "Set the temperature profile of water heater",
+ "description": "Sets the temperature profile for water heater",
"fields": {
"hour_00": {
"name": "00:00",
@@ -201,7 +201,7 @@
},
"set_v40_min": {
"name": "Set v40 min",
- "description": "Set the minimum quantity of water at 40°C for a heater",
+ "description": "Sets the minimum quantity of water at 40°C for a heater",
"fields": {
"v40_min": {
"name": "V40 Min",
@@ -211,7 +211,7 @@
},
"turn_off": {
"name": "Turn off heating",
- "description": "Turn off heating for one hour or until min temperature is reached",
+ "description": "Turns off heating for one hour or until min temperature is reached",
"fields": {
"until_temp_limit": {
"name": "Until temperature limit",
@@ -221,7 +221,7 @@
},
"turn_on": {
"name": "Turn on heating",
- "description": "Turn on heating for one hour or until max temperature is reached",
+ "description": "Turns on heating for one hour or until max temperature is reached",
"fields": {
"until_temp_limit": {
"name": "Until temperature limit",
diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py
index ff117d6577d..b3281193da3 100644
--- a/homeassistant/components/osoenergy/water_heater.py
+++ b/homeassistant/components/osoenergy/water_heater.py
@@ -20,7 +20,7 @@ from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import DOMAIN
diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py
index 6ddd392af7b..25380810862 100644
--- a/homeassistant/components/osramlightify/light.py
+++ b/homeassistant/components/osramlightify/light.py
@@ -24,10 +24,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json
index ca0faa160f0..f4029f4aa9e 100644
--- a/homeassistant/components/otbr/manifest.json
+++ b/homeassistant/components/otbr/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["python-otbr-api==2.6.0"]
+ "requirements": ["python-otbr-api==2.7.0"]
}
diff --git a/homeassistant/components/otp/config_flow.py b/homeassistant/components/otp/config_flow.py
index 33f63a04d68..8ddae9204c6 100644
--- a/homeassistant/components/otp/config_flow.py
+++ b/homeassistant/components/otp/config_flow.py
@@ -82,17 +82,6 @@ class TOTPConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import config from yaml."""
-
- await self.async_set_unique_id(import_data[CONF_TOKEN])
- self._abort_if_unique_id_configured()
-
- return self.async_create_entry(
- title=import_data.get(CONF_NAME, DEFAULT_NAME),
- data=import_data,
- )
-
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py
index 4119d02da8b..255bc0ded34 100644
--- a/homeassistant/components/otp/sensor.py
+++ b/homeassistant/components/otp/sensor.py
@@ -5,59 +5,20 @@ from __future__ import annotations
import time
import pyotp
-import voluptuous as vol
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorEntity,
-)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.components.sensor import SensorEntity
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_TOKEN
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.core import HomeAssistant, callback
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, StateType
+from homeassistant.helpers.typing import StateType
-from .const import DEFAULT_NAME, DOMAIN
+from .const import DOMAIN
TIME_STEP = 30 # Default time step assumed by Google Authenticator
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_TOKEN): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the OTP sensor."""
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- is_fixable=False,
- breaks_in_ha_version="2025.1.0",
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "One-Time Password (OTP)",
- },
- )
- await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config
- )
-
-
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py
index 2b4a0367bf7..51efb52e55d 100644
--- a/homeassistant/components/overkiz/__init__.py
+++ b/homeassistant/components/overkiz/__init__.py
@@ -41,6 +41,7 @@ from .const import (
PLATFORMS,
UPDATE_INTERVAL,
UPDATE_INTERVAL_ALL_ASSUMED_STATE,
+ UPDATE_INTERVAL_LOCAL,
)
from .coordinator import OverkizDataUpdateCoordinator
@@ -116,13 +117,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
if coordinator.is_stateless:
LOGGER.debug(
- (
- "All devices have an assumed state. Update interval has been reduced"
- " to: %s"
- ),
+ "All devices have an assumed state. Update interval has been reduced to: %s",
UPDATE_INTERVAL_ALL_ASSUMED_STATE,
)
- coordinator.update_interval = UPDATE_INTERVAL_ALL_ASSUMED_STATE
+ coordinator.set_update_interval(UPDATE_INTERVAL_ALL_ASSUMED_STATE)
+
+ if api_type == APIType.LOCAL:
+ LOGGER.debug(
+ "Devices connect via Local API. Update interval has been reduced to: %s",
+ UPDATE_INTERVAL_LOCAL,
+ )
+ coordinator.set_update_interval(UPDATE_INTERVAL_LOCAL)
platforms: defaultdict[Platform, list[Device]] = defaultdict(list)
diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py
index 1398bb7c25a..3276a1979cc 100644
--- a/homeassistant/components/overkiz/climate/__init__.py
+++ b/homeassistant/components/overkiz/climate/__init__.py
@@ -25,6 +25,7 @@ from .atlantic_pass_apc_heat_pump_main_component import (
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
+from .evo_home_controller import EvoHomeController
from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI
from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP
from .hitachi_air_to_water_heating_zone import HitachiAirToWaterHeatingZone
@@ -53,6 +54,7 @@ WIDGET_TO_CLIMATE_ENTITY = {
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone,
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl,
UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone,
+ UIWidget.EVO_HOME_CONTROLLER: EvoHomeController,
UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface,
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface,
diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py
index 5ba9dabe038..eff1d5fa130 100644
--- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py
+++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from asyncio import sleep
from typing import Any, cast
-from propcache import cached_property
+from propcache.api import cached_property
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
from homeassistant.components.climate import (
diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py
new file mode 100644
index 00000000000..e0cb8be7380
--- /dev/null
+++ b/homeassistant/components/overkiz/climate/evo_home_controller.py
@@ -0,0 +1,101 @@
+"""Support for EvoHomeController."""
+
+from datetime import timedelta
+
+from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
+
+from homeassistant.components.climate import (
+ PRESET_NONE,
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACMode,
+)
+from homeassistant.const import UnitOfTemperature
+from homeassistant.util import dt as dt_util
+
+from ..entity import OverkizDataUpdateCoordinator, OverkizEntity
+
+PRESET_DAY_OFF = "day-off"
+PRESET_HOLIDAYS = "holidays"
+
+OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = {
+ OverkizCommandParam.AUTO: HVACMode.AUTO,
+ OverkizCommandParam.OFF: HVACMode.OFF,
+}
+HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()}
+
+OVERKIZ_TO_PRESET_MODES: dict[str, str] = {
+ OverkizCommandParam.DAY_OFF: PRESET_DAY_OFF,
+ OverkizCommandParam.HOLIDAYS: PRESET_HOLIDAYS,
+}
+PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()}
+
+
+class EvoHomeController(OverkizEntity, ClimateEntity):
+ """Representation of EvoHomeController device."""
+
+ _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ]
+ _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
+ _attr_supported_features = (
+ ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF
+ )
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+
+ def __init__(
+ self, device_url: str, coordinator: OverkizDataUpdateCoordinator
+ ) -> None:
+ """Init method."""
+ super().__init__(device_url, coordinator)
+
+ if self._attr_device_info:
+ self._attr_device_info["manufacturer"] = "EvoHome"
+
+ @property
+ def hvac_mode(self) -> HVACMode:
+ """Return hvac operation ie. heat, cool mode."""
+ if state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE):
+ operating_mode = state.value_as_str
+
+ if operating_mode in OVERKIZ_TO_HVAC_MODES:
+ return OVERKIZ_TO_HVAC_MODES[operating_mode]
+
+ if operating_mode in OVERKIZ_TO_PRESET_MODES:
+ return HVACMode.OFF
+
+ return HVACMode.OFF
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set new target hvac mode."""
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_OPERATING_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode]
+ )
+
+ @property
+ def preset_mode(self) -> str | None:
+ """Return the current preset mode, e.g., home, away, temp."""
+ if (
+ state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE]
+ ) and state.value_as_str in OVERKIZ_TO_PRESET_MODES:
+ return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
+
+ return PRESET_NONE
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new preset mode."""
+ if preset_mode == PRESET_DAY_OFF:
+ today_end_of_day = dt_util.now().replace(
+ hour=0, minute=0, second=0, microsecond=0
+ ) + timedelta(days=1)
+ time_interval = today_end_of_day
+
+ if preset_mode == PRESET_HOLIDAYS:
+ one_week_from_now = dt_util.now().replace(
+ hour=0, minute=0, second=0, microsecond=0
+ ) + timedelta(days=7)
+ time_interval = one_week_from_now
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_OPERATING_MODE,
+ PRESET_MODES_TO_OVERKIZ[preset_mode],
+ time_interval.strftime("%Y/%m/%d %H:%M"),
+ )
diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py
index 9a94c30d95d..af955e5fb95 100644
--- a/homeassistant/components/overkiz/config_flow.py
+++ b/homeassistant/components/overkiz/config_flow.py
@@ -23,7 +23,6 @@ from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
import voluptuous as vol
-from homeassistant.components import dhcp, zeroconf
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
@@ -34,6 +33,8 @@ from homeassistant.const import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER
@@ -273,7 +274,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
hostname = discovery_info.hostname
@@ -284,7 +285,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._process_discovery(gateway_id)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle ZeroConf discovery."""
properties = discovery_info.properties
diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py
index 1a89fecf9c0..7f5f4ad85bd 100644
--- a/homeassistant/components/overkiz/const.py
+++ b/homeassistant/components/overkiz/const.py
@@ -44,6 +44,7 @@ DEFAULT_SERVER: Final = Server.SOMFY_EUROPE
DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443"
UPDATE_INTERVAL: Final = timedelta(seconds=30)
+UPDATE_INTERVAL_LOCAL: Final = timedelta(seconds=5)
UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60)
PLATFORMS: list[Platform] = [
@@ -101,6 +102,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported)
UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported)
+ UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported)
UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported)
diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py
index 17068d26b7c..484ef138cf7 100644
--- a/homeassistant/components/overkiz/coordinator.py
+++ b/homeassistant/components/overkiz/coordinator.py
@@ -26,7 +26,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.decorator import Registry
-from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
+from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER
EVENT_HANDLERS: Registry[
str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]]
@@ -36,6 +36,8 @@ EVENT_HANDLERS: Registry[
class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Class to manage fetching data from Overkiz platform."""
+ _default_update_interval: timedelta
+
def __init__(
self,
hass: HomeAssistant,
@@ -45,7 +47,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
client: OverkizClient,
devices: list[Device],
places: Place | None,
- update_interval: timedelta | None = None,
+ update_interval: timedelta,
config_entry_id: str,
) -> None:
"""Initialize global data updater."""
@@ -59,12 +61,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
self.data = {}
self.client = client
self.devices: dict[str, Device] = {d.device_url: d for d in devices}
- self.is_stateless = all(
- device.protocol in (Protocol.RTS, Protocol.INTERNAL) for device in devices
- )
self.executions: dict[str, dict[str, str]] = {}
self.areas = self._places_to_area(places) if places else None
self.config_entry_id = config_entry_id
+ self._default_update_interval = update_interval
+
+ self.is_stateless = all(
+ device.protocol in (Protocol.RTS, Protocol.INTERNAL)
+ for device in devices
+ if device.widget not in IGNORED_OVERKIZ_DEVICES
+ and device.ui_class not in IGNORED_OVERKIZ_DEVICES
+ )
async def _async_update_data(self) -> dict[str, Device]:
"""Fetch Overkiz data via event listener."""
@@ -102,8 +109,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
if event_handler := EVENT_HANDLERS.get(event.name):
await event_handler(self, event)
+ # Restore the default update interval if no executions are pending
if not self.executions:
- self.update_interval = UPDATE_INTERVAL
+ self.update_interval = self._default_update_interval
return self.devices
@@ -124,6 +132,11 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
return areas
+ def set_update_interval(self, update_interval: timedelta) -> None:
+ """Set the update interval and store this value."""
+ self.update_interval = update_interval
+ self._default_update_interval = update_interval
+
@EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE)
async def on_device_available(
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index 3b093eb06ac..eda39821d5c 100644
--- a/homeassistant/components/overkiz/manifest.json
+++ b/homeassistant/components/overkiz/manifest.json
@@ -1,14 +1,7 @@
{
"domain": "overkiz",
"name": "Overkiz",
- "codeowners": [
- "@imicknl",
- "@vlebourl",
- "@tetienne",
- "@nyroDev",
- "@tronix117",
- "@alexfp14"
- ],
+ "codeowners": ["@imicknl"],
"config_flow": true,
"dhcp": [
{
diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py
index 84d25b01d24..81a9ab41d2d 100644
--- a/homeassistant/components/overkiz/sensor.py
+++ b/homeassistant/components/overkiz/sensor.py
@@ -534,8 +534,7 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
# This is probably incorrect and should be fixed in a follow up PR.
# To ensure measurement sensors do not get an `unknown` state on
# a falsy value (e.g. 0 or 0.0) we also check the state_class.
- or self.state_class != SensorStateClass.MEASUREMENT
- and not state.value
+ or (self.state_class != SensorStateClass.MEASUREMENT and not state.value)
):
return None
diff --git a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py
index abd3f40adc2..f5a9e3d4a7e 100644
--- a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py
+++ b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py
@@ -64,10 +64,8 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
for param, mode in OVERKIZ_TO_OPERATION_MODE.items():
# Filter only for mode allowed by this device
# or allow all if no mode definition found
- if (
- not state_mode_definition
- or state_mode_definition.values
- and param in state_mode_definition.values
+ if not state_mode_definition or (
+ state_mode_definition.values and param in state_mode_definition.values
):
self.operation_mode_to_overkiz[mode] = param
self._attr_operation_list.append(param)
diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py
new file mode 100644
index 00000000000..597d44f66cf
--- /dev/null
+++ b/homeassistant/components/overseerr/__init__.py
@@ -0,0 +1,182 @@
+"""The Overseerr integration."""
+
+from __future__ import annotations
+
+import json
+from typing import cast
+
+from aiohttp.hdrs import METH_POST
+from aiohttp.web_request import Request
+from aiohttp.web_response import Response
+from python_overseerr import OverseerrConnectionError
+
+from homeassistant.components import cloud
+from homeassistant.components.webhook import (
+ async_generate_url,
+ async_register,
+ async_unregister,
+)
+from homeassistant.const import CONF_WEBHOOK_ID, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.http import HomeAssistantView
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS
+from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
+from .services import setup_services
+
+PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
+CONF_CLOUDHOOK_URL = "cloudhook_url"
+
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Overseerr component."""
+ setup_services(hass)
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool:
+ """Set up Overseerr from a config entry."""
+
+ coordinator = OverseerrCoordinator(hass, entry)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+
+ webhook_manager = OverseerrWebhookManager(hass, entry)
+
+ try:
+ await webhook_manager.register_webhook()
+ except OverseerrConnectionError:
+ LOGGER.error("Failed to register Overseerr webhook")
+
+ entry.async_on_unload(webhook_manager.unregister_webhook)
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None:
+ """Cleanup when entry is removed."""
+ if cloud.async_active_subscription(hass):
+ try:
+ LOGGER.debug(
+ "Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
+ )
+ await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
+ except cloud.CloudNotAvailable:
+ pass
+
+
+class OverseerrWebhookManager:
+ """Overseerr webhook manager."""
+
+ def __init__(self, hass: HomeAssistant, entry: OverseerrConfigEntry) -> None:
+ """Initialize Overseerr webhook manager."""
+ self.hass = hass
+ self.entry = entry
+ self.client = entry.runtime_data.client
+
+ @property
+ def webhook_urls(self) -> list[str]:
+ """Return webhook URLs."""
+ urls = [
+ async_generate_url(
+ self.hass, self.entry.data[CONF_WEBHOOK_ID], prefer_external=external
+ )
+ for external in (False, True)
+ ]
+ res = []
+ for url in urls:
+ if url not in res:
+ res.append(url)
+ if CONF_CLOUDHOOK_URL in self.entry.data:
+ res.append(self.entry.data[CONF_CLOUDHOOK_URL])
+ return res
+
+ async def register_webhook(self) -> None:
+ """Register webhook."""
+ async_register(
+ self.hass,
+ DOMAIN,
+ self.entry.title,
+ self.entry.data[CONF_WEBHOOK_ID],
+ self.handle_webhook,
+ allowed_methods=[METH_POST],
+ )
+ if not await self.check_need_change():
+ self.entry.runtime_data.push = True
+ return
+ for url in self.webhook_urls:
+ if await self.test_and_set_webhook(url):
+ return
+ LOGGER.info("Failed to register Overseerr webhook")
+ if cloud.async_active_subscription(self.hass):
+ LOGGER.info("Trying to register a cloudhook URL")
+ url = await _async_cloudhook_generate_url(self.hass, self.entry)
+ if await self.test_and_set_webhook(url):
+ return
+ LOGGER.error("Failed to register Overseerr cloudhook")
+
+ async def check_need_change(self) -> bool:
+ """Check if webhook needs to be changed."""
+ current_config = await self.client.get_webhook_notification_config()
+ return (
+ not current_config.enabled
+ or current_config.options.webhook_url not in self.webhook_urls
+ or current_config.options.json_payload != json.loads(JSON_PAYLOAD)
+ or current_config.types != REGISTERED_NOTIFICATIONS
+ )
+
+ async def test_and_set_webhook(self, url: str) -> bool:
+ """Test and set webhook."""
+ if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD):
+ LOGGER.debug("Setting Overseerr webhook to %s", url)
+ await self.client.set_webhook_notification_config(
+ enabled=True,
+ types=REGISTERED_NOTIFICATIONS,
+ webhook_url=url,
+ json_payload=JSON_PAYLOAD,
+ )
+ self.entry.runtime_data.push = True
+ return True
+ return False
+
+ async def handle_webhook(
+ self, hass: HomeAssistant, webhook_id: str, request: Request
+ ) -> Response:
+ """Handle webhook."""
+ data = await request.json()
+ LOGGER.debug("Received webhook payload: %s", data)
+ if data["notification_type"].startswith("MEDIA"):
+ await self.entry.runtime_data.async_refresh()
+ async_dispatcher_send(hass, EVENT_KEY, data)
+ return HomeAssistantView.json({"message": "ok"})
+
+ async def unregister_webhook(self) -> None:
+ """Unregister webhook."""
+ async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
+
+
+async def _async_cloudhook_generate_url(
+ hass: HomeAssistant, entry: OverseerrConfigEntry
+) -> str:
+ """Generate the full URL for a webhook_id."""
+ if CONF_CLOUDHOOK_URL not in entry.data:
+ webhook_id = entry.data[CONF_WEBHOOK_ID]
+ webhook_url = await cloud.async_create_cloudhook(hass, webhook_id)
+ data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
+ hass.config_entries.async_update_entry(entry, data=data)
+ return webhook_url
+ return cast(str, entry.data[CONF_CLOUDHOOK_URL])
diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py
new file mode 100644
index 00000000000..9a8bdd1676f
--- /dev/null
+++ b/homeassistant/components/overseerr/config_flow.py
@@ -0,0 +1,136 @@
+"""Config flow for Overseerr."""
+
+from collections.abc import Mapping
+from typing import Any
+
+from python_overseerr import (
+ OverseerrAuthenticationError,
+ OverseerrClient,
+ OverseerrError,
+)
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.components.webhook import async_generate_id
+from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_HOST,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_URL,
+ CONF_WEBHOOK_ID,
+)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+
+class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Overseerr config flow."""
+
+ async def _check_connection(
+ self, host: str, port: int, ssl: bool, api_key: str
+ ) -> str | None:
+ """Check if we can connect to the Overseerr instance."""
+ client = OverseerrClient(
+ host,
+ port,
+ api_key,
+ ssl=ssl,
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.get_request_count()
+ except OverseerrAuthenticationError:
+ return "invalid_auth"
+ except OverseerrError:
+ return "cannot_connect"
+ return None
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ errors: dict[str, str] = {}
+ if user_input:
+ url = URL(user_input[CONF_URL])
+ if (host := url.host) is None:
+ errors[CONF_URL] = "invalid_host"
+ else:
+ self._async_abort_entries_match({CONF_HOST: host})
+ port = url.port
+ assert port
+ error = await self._check_connection(
+ host, port, url.scheme == "https", user_input[CONF_API_KEY]
+ )
+ if error:
+ errors["base"] = error
+ else:
+ if self.source == SOURCE_USER:
+ return self.async_create_entry(
+ title="Overseerr",
+ data={
+ CONF_HOST: host,
+ CONF_PORT: port,
+ CONF_SSL: url.scheme == "https",
+ CONF_API_KEY: user_input[CONF_API_KEY],
+ CONF_WEBHOOK_ID: async_generate_id(),
+ },
+ )
+ reconfigure_entry = self._get_reconfigure_entry()
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data={
+ **reconfigure_entry.data,
+ CONF_HOST: host,
+ CONF_PORT: port,
+ CONF_SSL: url.scheme == "https",
+ CONF_API_KEY: user_input[CONF_API_KEY],
+ },
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str}
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reauth(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-auth."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle re-auth confirmation."""
+ errors: dict[str, str] = {}
+ if user_input:
+ entry = self._get_reauth_entry()
+ error = await self._check_connection(
+ entry.data[CONF_HOST],
+ entry.data[CONF_PORT],
+ entry.data[CONF_SSL],
+ user_input[CONF_API_KEY],
+ )
+ if error:
+ errors["base"] = error
+ else:
+ return self.async_update_reload_and_abort(
+ entry,
+ data={**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]},
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration."""
+ return await self.async_step_user()
diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py
new file mode 100644
index 00000000000..5c33ca3fcec
--- /dev/null
+++ b/homeassistant/components/overseerr/const.py
@@ -0,0 +1,48 @@
+"""Constants for the overseerr integration."""
+
+import logging
+
+from python_overseerr.models import NotificationType
+
+DOMAIN = "overseerr"
+LOGGER = logging.getLogger(__package__)
+
+REQUESTS = "requests"
+
+ATTR_CONFIG_ENTRY_ID = "config_entry_id"
+ATTR_STATUS = "status"
+ATTR_SORT_ORDER = "sort_order"
+ATTR_REQUESTED_BY = "requested_by"
+
+EVENT_KEY = f"{DOMAIN}_event"
+
+REGISTERED_NOTIFICATIONS = (
+ NotificationType.REQUEST_PENDING_APPROVAL
+ | NotificationType.REQUEST_APPROVED
+ | NotificationType.REQUEST_DECLINED
+ | NotificationType.REQUEST_AVAILABLE
+ | NotificationType.REQUEST_PROCESSING_FAILED
+ | NotificationType.REQUEST_AUTOMATICALLY_APPROVED
+)
+JSON_PAYLOAD = (
+ '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}'
+ '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":'
+ '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t'
+ 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k'
+ '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id'
+ '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna'
+ 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a'
+ 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco'
+ 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_'
+ 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_'
+ 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte'
+ 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported'
+ 'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported'
+ '_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by'
+ '_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{'
+ 'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema'
+ 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us'
+ 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b'
+ 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by'
+ '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"'
+)
diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py
new file mode 100644
index 00000000000..2149dcbec7c
--- /dev/null
+++ b/homeassistant/components/overseerr/coordinator.py
@@ -0,0 +1,66 @@
+"""Define an object to coordinate fetching Overseerr data."""
+
+from datetime import timedelta
+
+from python_overseerr import (
+ OverseerrAuthenticationError,
+ OverseerrClient,
+ OverseerrConnectionError,
+ RequestCount,
+)
+from yarl import URL
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, LOGGER
+
+type OverseerrConfigEntry = ConfigEntry[OverseerrCoordinator]
+
+
+class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]):
+ """Class to manage fetching Overseerr data."""
+
+ config_entry: OverseerrConfigEntry
+
+ def __init__(self, hass: HomeAssistant, entry: OverseerrConfigEntry) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ config_entry=entry,
+ update_interval=timedelta(minutes=5),
+ )
+ host = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+ ssl = entry.data[CONF_SSL]
+ self.client = OverseerrClient(
+ host,
+ port,
+ entry.data[CONF_API_KEY],
+ ssl=ssl,
+ session=async_get_clientsession(hass),
+ )
+ self.url = URL.build(host=host, port=port, scheme="https" if ssl else "http")
+ self.push = False
+
+ async def _async_update_data(self) -> RequestCount:
+ """Fetch data from API endpoint."""
+ try:
+ return await self.client.get_request_count()
+ except OverseerrAuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ ) from err
+ except OverseerrConnectionError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
diff --git a/homeassistant/components/overseerr/diagnostics.py b/homeassistant/components/overseerr/diagnostics.py
new file mode 100644
index 00000000000..d45e1441e23
--- /dev/null
+++ b/homeassistant/components/overseerr/diagnostics.py
@@ -0,0 +1,26 @@
+"""Diagnostics support for Overseerr."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import CONF_CLOUDHOOK_URL
+from .coordinator import OverseerrConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: OverseerrConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data
+
+ data = entry.runtime_data
+
+ return {
+ "has_cloudhooks": has_cloudhooks,
+ "coordinator_data": asdict(data.data),
+ }
diff --git a/homeassistant/components/overseerr/entity.py b/homeassistant/components/overseerr/entity.py
new file mode 100644
index 00000000000..937ad52f7ec
--- /dev/null
+++ b/homeassistant/components/overseerr/entity.py
@@ -0,0 +1,23 @@
+"""Base entity for Overseerr."""
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import OverseerrCoordinator
+
+
+class OverseerrEntity(CoordinatorEntity[OverseerrCoordinator]):
+ """Defines a base Overseerr entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: OverseerrCoordinator, key: str) -> None:
+ """Initialize Overseerr entity."""
+ super().__init__(coordinator)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
+ entry_type=DeviceEntryType.SERVICE,
+ configuration_url=coordinator.url,
+ )
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}"
diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py
new file mode 100644
index 00000000000..589a80c5404
--- /dev/null
+++ b/homeassistant/components/overseerr/event.py
@@ -0,0 +1,125 @@
+"""Support for Overseerr events."""
+
+from dataclasses import dataclass
+from typing import Any
+
+from homeassistant.components.event import EventEntity, EventEntityDescription
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import DOMAIN, EVENT_KEY
+from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
+from .entity import OverseerrEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class OverseerrEventEntityDescription(EventEntityDescription):
+ """Describes Overseerr config event entity."""
+
+ nullable_fields: list[str]
+
+
+EVENTS: tuple[OverseerrEventEntityDescription, ...] = (
+ OverseerrEventEntityDescription(
+ key="media",
+ translation_key="last_media_event",
+ event_types=[
+ "pending",
+ "approved",
+ "available",
+ "failed",
+ "declined",
+ "auto_approved",
+ ],
+ nullable_fields=["comment", "issue"],
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: OverseerrConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Overseerr sensor entities based on a config entry."""
+
+ coordinator = entry.runtime_data
+ ent_reg = er.async_get(hass)
+
+ event_entities_setup_before = ent_reg.async_get_entity_id(
+ Platform.EVENT, DOMAIN, f"{entry.entry_id}-media"
+ )
+
+ if coordinator.push or event_entities_setup_before:
+ async_add_entities(
+ OverseerrEvent(coordinator, description) for description in EVENTS
+ )
+
+
+class OverseerrEvent(OverseerrEntity, EventEntity):
+ """Defines a Overseerr event entity."""
+
+ entity_description: OverseerrEventEntityDescription
+
+ def __init__(
+ self,
+ coordinator: OverseerrCoordinator,
+ description: OverseerrEventEntityDescription,
+ ) -> None:
+ """Initialize Overseerr event entity."""
+ super().__init__(coordinator, description.key)
+ self.entity_description = description
+ self._attr_available = True
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to updates."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update)
+ )
+
+ async def _handle_update(self, event: dict[str, Any]) -> None:
+ """Handle incoming event."""
+ event_type = event["notification_type"].lower()
+ if event_type.split("_")[0] == self.entity_description.key:
+ self._attr_entity_picture = event.get("image")
+ self._trigger_event(
+ event_type[6:],
+ parse_event(event, self.entity_description.nullable_fields),
+ )
+ self.async_write_ha_state()
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ if super().available != self._attr_available:
+ self._attr_available = super().available
+ super()._handle_coordinator_update()
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._attr_available and self.coordinator.push
+
+
+def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]:
+ """Parse event."""
+ event.pop("notification_type")
+ event.pop("image")
+ for field in nullable_fields:
+ event.pop(field)
+ if (media := event.get("media")) is not None:
+ for field in ("status", "status4k"):
+ media[field] = media[field].lower()
+ for field in ("tmdb_id", "tvdb_id"):
+ if (value := media.get(field)) != "":
+ media[field] = int(value)
+ else:
+ media[field] = None
+ if (request := event.get("request")) is not None:
+ request["request_id"] = int(request["request_id"])
+ return event
diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json
new file mode 100644
index 00000000000..af18836680b
--- /dev/null
+++ b/homeassistant/components/overseerr/icons.json
@@ -0,0 +1,37 @@
+{
+ "entity": {
+ "sensor": {
+ "total_requests": {
+ "default": "mdi:forum"
+ },
+ "movie_requests": {
+ "default": "mdi:movie-open"
+ },
+ "tv_requests": {
+ "default": "mdi:television-box"
+ },
+ "pending_requests": {
+ "default": "mdi:clock"
+ },
+ "declined_requests": {
+ "default": "mdi:movie-open-off"
+ },
+ "processing_requests": {
+ "default": "mdi:sync"
+ },
+ "available_requests": {
+ "default": "mdi:message-bulleted"
+ }
+ },
+ "event": {
+ "last_media_event": {
+ "default": "mdi:multimedia"
+ }
+ }
+ },
+ "services": {
+ "get_requests": {
+ "service": "mdi:multimedia"
+ }
+ }
+}
diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json
new file mode 100644
index 00000000000..396b9d7000b
--- /dev/null
+++ b/homeassistant/components/overseerr/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "overseerr",
+ "name": "Overseerr",
+ "after_dependencies": ["cloud"],
+ "codeowners": ["@joostlek"],
+ "config_flow": true,
+ "dependencies": ["http", "webhook"],
+ "documentation": "https://www.home-assistant.io/integrations/overseerr",
+ "integration_type": "service",
+ "iot_class": "local_push",
+ "quality_scale": "platinum",
+ "requirements": ["python-overseerr==0.6.0"]
+}
diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml
new file mode 100644
index 00000000000..7afbcd6aa07
--- /dev/null
+++ b/homeassistant/components/overseerr/quality_scale.yaml
@@ -0,0 +1,89 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options to configure
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: done
+ comment: Handled by the coordinator
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: Handled by the coordinator
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration does not support discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+ entity-category: done
+ entity-device-class:
+ status: exempt
+ comment: |
+ This integration has no relevant device class to use.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration has no unpopular entities to disable.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py
new file mode 100644
index 00000000000..2daaa3de0cb
--- /dev/null
+++ b/homeassistant/components/overseerr/sensor.py
@@ -0,0 +1,107 @@
+"""Support for Overseerr sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from python_overseerr import RequestCount
+
+from homeassistant.components.sensor import (
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import REQUESTS
+from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
+from .entity import OverseerrEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class OverseerrSensorEntityDescription(SensorEntityDescription):
+ """Describes Overseerr config sensor entity."""
+
+ value_fn: Callable[[RequestCount], int]
+
+
+SENSORS: tuple[OverseerrSensorEntityDescription, ...] = (
+ OverseerrSensorEntityDescription(
+ key="total_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.total,
+ ),
+ OverseerrSensorEntityDescription(
+ key="movie_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.movie,
+ ),
+ OverseerrSensorEntityDescription(
+ key="tv_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.tv,
+ ),
+ OverseerrSensorEntityDescription(
+ key="pending_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.pending,
+ ),
+ OverseerrSensorEntityDescription(
+ key="declined_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.declined,
+ ),
+ OverseerrSensorEntityDescription(
+ key="processing_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.processing,
+ ),
+ OverseerrSensorEntityDescription(
+ key="available_requests",
+ native_unit_of_measurement=REQUESTS,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda count: count.available,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: OverseerrConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Overseerr sensor entities based on a config entry."""
+
+ coordinator = entry.runtime_data
+ async_add_entities(
+ OverseerrSensor(coordinator, description) for description in SENSORS
+ )
+
+
+class OverseerrSensor(OverseerrEntity, SensorEntity):
+ """Defines an Overseerr sensor."""
+
+ entity_description: OverseerrSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: OverseerrCoordinator,
+ description: OverseerrSensorEntityDescription,
+ ) -> None:
+ """Initialize airgradient sensor."""
+ super().__init__(coordinator, description.key)
+ self.entity_description = description
+ self._attr_translation_key = description.key
+
+ @property
+ def native_value(self) -> int:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py
new file mode 100644
index 00000000000..4631e578af8
--- /dev/null
+++ b/homeassistant/components/overseerr/services.py
@@ -0,0 +1,115 @@
+"""Define services for the Overseerr integration."""
+
+from dataclasses import asdict
+from typing import Any, cast
+
+from python_overseerr import OverseerrClient, OverseerrConnectionError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.util.json import JsonValueType
+
+from .const import (
+ ATTR_CONFIG_ENTRY_ID,
+ ATTR_REQUESTED_BY,
+ ATTR_SORT_ORDER,
+ ATTR_STATUS,
+ DOMAIN,
+ LOGGER,
+)
+from .coordinator import OverseerrConfigEntry
+
+SERVICE_GET_REQUESTS = "get_requests"
+SERVICE_GET_REQUESTS_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Optional(ATTR_STATUS): vol.In(
+ ["approved", "pending", "available", "processing", "unavailable", "failed"]
+ ),
+ vol.Optional(ATTR_SORT_ORDER): vol.In(["added", "modified"]),
+ vol.Optional(ATTR_REQUESTED_BY): int,
+ }
+)
+
+
+def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfigEntry:
+ """Get the Overseerr config entry."""
+ if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="integration_not_found",
+ translation_placeholders={"target": DOMAIN},
+ )
+ if entry.state is not ConfigEntryState.LOADED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="not_loaded",
+ translation_placeholders={"target": entry.title},
+ )
+ return cast(OverseerrConfigEntry, entry)
+
+
+async def get_media(
+ client: OverseerrClient, media_type: str, identifier: int
+) -> dict[str, Any]:
+ """Get media details."""
+ media = {}
+ try:
+ if media_type == "movie":
+ media = asdict(await client.get_movie_details(identifier))
+ if media_type == "tv":
+ media = asdict(await client.get_tv_details(identifier))
+ except OverseerrConnectionError:
+ LOGGER.error("Could not find data for %s %s", media_type, identifier)
+ return {}
+ media["media_info"].pop("requests")
+ return media
+
+
+def setup_services(hass: HomeAssistant) -> None:
+ """Set up the services for the Overseerr integration."""
+
+ async def async_get_requests(call: ServiceCall) -> ServiceResponse:
+ """Get requests made to Overseerr."""
+ entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
+ client = entry.runtime_data.client
+ kwargs: dict[str, Any] = {}
+ if status := call.data.get(ATTR_STATUS):
+ kwargs["status"] = status
+ if sort_order := call.data.get(ATTR_SORT_ORDER):
+ kwargs["sort"] = sort_order
+ if requested_by := call.data.get(ATTR_REQUESTED_BY):
+ kwargs["requested_by"] = requested_by
+ try:
+ requests = await client.get_requests(**kwargs)
+ except OverseerrConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
+ result: list[dict[str, Any]] = []
+ for request in requests:
+ req = asdict(request)
+ assert request.media.tmdb_id
+ req["media"] = await get_media(
+ client, request.media.media_type, request.media.tmdb_id
+ )
+ result.append(req)
+
+ return {"requests": cast(list[JsonValueType], result)}
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_REQUESTS,
+ async_get_requests,
+ schema=SERVICE_GET_REQUESTS_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml
new file mode 100644
index 00000000000..c7593fc5aee
--- /dev/null
+++ b/homeassistant/components/overseerr/services.yaml
@@ -0,0 +1,30 @@
+get_requests:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: overseerr
+ status:
+ selector:
+ select:
+ options:
+ - approved
+ - pending
+ - available
+ - processing
+ - unavailable
+ - failed
+ translation_key: request_status
+ sort_order:
+ selector:
+ select:
+ options:
+ - added
+ - modified
+ translation_key: request_sort_order
+ requested_by:
+ selector:
+ number:
+ min: 0
+ mode: box
diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json
new file mode 100644
index 00000000000..14650fd5c25
--- /dev/null
+++ b/homeassistant/components/overseerr/strings.json
@@ -0,0 +1,132 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "url": "The URL of the Overseerr instance.",
+ "api_key": "The API key of the Overseerr instance."
+ }
+ },
+ "reauth_confirm": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::overseerr::config::step::user::data_description::api_key%]"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "Authentication failed. Your API key is invalid or CSRF protection is turned on, preventing authentication.",
+ "invalid_host": "The provided URL is not a valid host."
+ }
+ },
+ "entity": {
+ "event": {
+ "last_media_event": {
+ "name": "Last media event",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "pending": "Pending",
+ "approved": "Approved",
+ "available": "Available",
+ "failed": "Failed",
+ "declined": "Declined",
+ "auto_approved": "Auto-approved"
+ }
+ }
+ }
+ }
+ },
+ "sensor": {
+ "total_requests": {
+ "name": "Total requests"
+ },
+ "movie_requests": {
+ "name": "Movie requests"
+ },
+ "tv_requests": {
+ "name": "TV requests"
+ },
+ "pending_requests": {
+ "name": "Pending requests"
+ },
+ "declined_requests": {
+ "name": "Declined requests"
+ },
+ "processing_requests": {
+ "name": "Processing requests"
+ },
+ "available_requests": {
+ "name": "Available requests"
+ }
+ }
+ },
+ "exceptions": {
+ "connection_error": {
+ "message": "Error connecting to the Overseerr instance: {error}"
+ },
+ "auth_error": {
+ "message": "Invalid API key."
+ },
+ "not_loaded": {
+ "message": "{target} is not loaded."
+ },
+ "integration_not_found": {
+ "message": "Integration \"{target}\" not found in registry."
+ }
+ },
+ "services": {
+ "get_requests": {
+ "name": "Get requests",
+ "description": "Get media requests from Overseerr.",
+ "fields": {
+ "config_entry_id": {
+ "name": "Overseerr instance",
+ "description": "The Overseerr instance to get requests from."
+ },
+ "status": {
+ "name": "Request status",
+ "description": "Filter the requests by status."
+ },
+ "sort_order": {
+ "name": "Sort order",
+ "description": "Sort the requests by added or modified date."
+ },
+ "requested_by": {
+ "name": "Requested by",
+ "description": "Filter the requests by the user id that requested them."
+ }
+ }
+ }
+ },
+ "selector": {
+ "request_status": {
+ "options": {
+ "approved": "Approved",
+ "pending": "Pending",
+ "available": "Available",
+ "processing": "Processing",
+ "unavailable": "Unavailable",
+ "failed": "Failed"
+ }
+ },
+ "request_sort_order": {
+ "options": {
+ "added": "Added",
+ "modified": "Modified"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py
index 720c3718a4f..623e5e17b66 100644
--- a/homeassistant/components/owntracks/__init__.py
+++ b/homeassistant/components/owntracks/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py
index f20b3d11261..dbf1baa0c28 100644
--- a/homeassistant/components/palazzetti/__init__.py
+++ b/homeassistant/components/palazzetti/__init__.py
@@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant
from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator
-PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
+PLATFORMS: list[Platform] = [
+ Platform.BUTTON,
+ Platform.CLIMATE,
+ Platform.NUMBER,
+ Platform.SENSOR,
+]
async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool:
diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py
new file mode 100644
index 00000000000..cd4765576ed
--- /dev/null
+++ b/homeassistant/components/palazzetti/button.py
@@ -0,0 +1,52 @@
+"""Support for Palazzetti buttons."""
+
+from __future__ import annotations
+
+from pypalazzetti.exceptions import CommunicationError
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import PalazzettiConfigEntry
+from .const import DOMAIN
+from .coordinator import PalazzettiDataUpdateCoordinator
+from .entity import PalazzettiEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PalazzettiConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Palazzetti button platform."""
+
+ coordinator = config_entry.runtime_data
+ if coordinator.client.has_fan_silent:
+ async_add_entities([PalazzettiSilentButtonEntity(coordinator)])
+
+
+class PalazzettiSilentButtonEntity(PalazzettiEntity, ButtonEntity):
+ """Representation of a Palazzetti Silent button."""
+
+ _attr_translation_key = "silent"
+
+ def __init__(
+ self,
+ coordinator: PalazzettiDataUpdateCoordinator,
+ ) -> None:
+ """Initialize a Palazzetti Silent button."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}-silent"
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ try:
+ await self.coordinator.client.set_fan_silent()
+ except CommunicationError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="cannot_connect"
+ ) from err
+
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py
index 356f3a7306f..0722b97e4b7 100644
--- a/homeassistant/components/palazzetti/climate.py
+++ b/homeassistant/components/palazzetti/climate.py
@@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import PalazzettiConfigEntry
-from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT
+from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES
from .coordinator import PalazzettiDataUpdateCoordinator
from .entity import PalazzettiEntity
@@ -57,8 +57,6 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity):
self._attr_fan_modes = list(
map(str, range(client.fan_speed_min, client.fan_speed_max + 1))
)
- if client.has_fan_silent:
- self._attr_fan_modes.insert(0, FAN_SILENT)
if client.has_fan_high:
self._attr_fan_modes.append(FAN_HIGH)
if client.has_fan_auto:
@@ -124,15 +122,13 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the fan mode."""
- api_state = self.coordinator.client.fan_speed
+ api_state = self.coordinator.client.current_fan_speed()
return FAN_MODES[api_state]
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
try:
- if fan_mode == FAN_SILENT:
- await self.coordinator.client.set_fan_silent()
- elif fan_mode == FAN_HIGH:
+ if fan_mode == FAN_HIGH:
await self.coordinator.client.set_fan_high()
elif fan_mode == FAN_AUTO:
await self.coordinator.client.set_fan_auto()
diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py
index fe892b6624d..91762216ff5 100644
--- a/homeassistant/components/palazzetti/config_flow.py
+++ b/homeassistant/components/palazzetti/config_flow.py
@@ -6,10 +6,10 @@ from pypalazzetti.client import PalazzettiClient
from pypalazzetti.exceptions import CommunicationError
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN, LOGGER
@@ -53,7 +53,7 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py
index b2e27b2a6fd..1b68cf99f9d 100644
--- a/homeassistant/components/palazzetti/const.py
+++ b/homeassistant/components/palazzetti/const.py
@@ -18,7 +18,7 @@ ERROR_CANNOT_CONNECT = "cannot_connect"
FAN_SILENT: Final = "silent"
FAN_HIGH: Final = "high"
FAN_AUTO: Final = "auto"
-FAN_MODES: Final = [FAN_SILENT, "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO]
+FAN_MODES: Final = ["0", "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO]
STATUS_TO_HA: Final[dict[StateType, str]] = {
0: "off",
diff --git a/homeassistant/components/palazzetti/icons.json b/homeassistant/components/palazzetti/icons.json
new file mode 100644
index 00000000000..c20a9572618
--- /dev/null
+++ b/homeassistant/components/palazzetti/icons.json
@@ -0,0 +1,9 @@
+{
+ "entity": {
+ "button": {
+ "silent": {
+ "default": "mdi:volume-mute"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json
index 70e58507159..41e8e0fb4de 100644
--- a/homeassistant/components/palazzetti/manifest.json
+++ b/homeassistant/components/palazzetti/manifest.json
@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["pypalazzetti==0.1.15"]
+ "requirements": ["pypalazzetti==0.1.19"]
}
diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py
index 06114bfef54..2b303f71fd6 100644
--- a/homeassistant/components/palazzetti/number.py
+++ b/homeassistant/components/palazzetti/number.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from pypalazzetti.exceptions import CommunicationError, ValidationError
+from pypalazzetti.fan import FanType
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.core import HomeAssistant
@@ -21,7 +22,18 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Palazzetti number platform."""
- async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)])
+
+ entities: list[PalazzettiEntity] = [
+ PalazzettiCombustionPowerEntity(config_entry.runtime_data)
+ ]
+
+ if config_entry.runtime_data.client.has_fan(FanType.LEFT):
+ entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.LEFT))
+
+ if config_entry.runtime_data.client.has_fan(FanType.RIGHT):
+ entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.RIGHT))
+
+ async_add_entities(entities)
class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity):
@@ -64,3 +76,49 @@ class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity):
) from err
await self.coordinator.async_request_refresh()
+
+
+class PalazzettiFanEntity(PalazzettiEntity, NumberEntity):
+ """Representation of Palazzetti number entity for Combustion power."""
+
+ _attr_device_class = NumberDeviceClass.WIND_SPEED
+ _attr_native_step = 1
+
+ def __init__(
+ self, coordinator: PalazzettiDataUpdateCoordinator, fan: FanType
+ ) -> None:
+ """Initialize the Palazzetti number entity."""
+ super().__init__(coordinator)
+ self.fan = fan
+
+ self._attr_translation_key = f"fan_{str.lower(fan.name)}_speed"
+ self._attr_native_min_value = coordinator.client.min_fan_speed(fan)
+ self._attr_native_max_value = coordinator.client.max_fan_speed(fan)
+ self._attr_unique_id = (
+ f"{coordinator.config_entry.unique_id}-fan_{str.lower(fan.name)}_speed"
+ )
+
+ @property
+ def native_value(self) -> float:
+ """Return the state of the setting entity."""
+ return self.coordinator.client.current_fan_speed(self.fan)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Update the setting."""
+ try:
+ await self.coordinator.client.set_fan_speed(int(value), self.fan)
+ except CommunicationError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="cannot_connect"
+ ) from err
+ except ValidationError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_fan_speed",
+ translation_placeholders={
+ "name": str.lower(self.fan.name),
+ "value": str(value),
+ },
+ ) from err
+
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml
index 493b2595117..ff8461ad193 100644
--- a/homeassistant/components/palazzetti/quality_scale.yaml
+++ b/homeassistant/components/palazzetti/quality_scale.yaml
@@ -15,8 +15,8 @@ rules:
comment: |
This integration does not register actions.
docs-high-level-description: done
- docs-installation-instructions: todo
- docs-removal-instructions: todo
+ docs-installation-instructions: done
+ docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
@@ -35,7 +35,7 @@ rules:
status: exempt
comment: |
This integration does not have configuration.
- docs-installation-parameters: todo
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -51,12 +51,12 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: todo
- docs-examples: todo
- docs-known-limitations: todo
- docs-supported-devices: todo
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
docs-supported-functions: done
- docs-troubleshooting: todo
- docs-use-cases: todo
+ docs-troubleshooting: done
+ docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
@@ -67,9 +67,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations:
- status: exempt
- comment: |
- This integration does not have custom icons.
+ status: done
reconfiguration-flow: todo
repair-issues:
status: exempt
diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json
index ad7bc498bd1..501ee777fe9 100644
--- a/homeassistant/components/palazzetti/strings.json
+++ b/homeassistant/components/palazzetti/strings.json
@@ -27,6 +27,9 @@
"invalid_fan_mode": {
"message": "Fan mode {value} is invalid."
},
+ "invalid_fan_speed": {
+ "message": "Fan {name} speed {value} is invalid."
+ },
"invalid_target_temperature": {
"message": "Target temperature {value} is invalid."
},
@@ -38,6 +41,11 @@
}
},
"entity": {
+ "button": {
+ "silent": {
+ "name": "Silent"
+ }
+ },
"climate": {
"palazzetti": {
"state_attributes": {
@@ -54,6 +62,12 @@
"number": {
"combustion_power": {
"name": "Combustion power"
+ },
+ "fan_left_speed": {
+ "name": "Left fan speed"
+ },
+ "fan_right_speed": {
+ "name": "Right fan speed"
}
},
"sensor": {
diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py
index a7cb0780ca9..b0e23031a24 100644
--- a/homeassistant/components/panasonic_bluray/media_player.py
+++ b/homeassistant/components/panasonic_bluray/media_player.py
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import utcnow
diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py
index 69800d2ef1e..6dacc08077d 100644
--- a/homeassistant/components/panasonic_viera/__init__.py
+++ b/homeassistant/components/panasonic_viera/__init__.py
@@ -13,7 +13,7 @@ from homeassistant.components.media_player import MediaPlayerState, MediaType
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform
from homeassistant.core import Context, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/pandora/manifest.json b/homeassistant/components/pandora/manifest.json
index e7d8946fb38..e67dbac27db 100644
--- a/homeassistant/components/pandora/manifest.json
+++ b/homeassistant/components/pandora/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
"quality_scale": "legacy",
- "requirements": ["pexpect==4.6.0"]
+ "requirements": ["pexpect==4.9.0"]
}
diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py
index f781f366173..0b2f5b7055f 100644
--- a/homeassistant/components/pandora/media_player.py
+++ b/homeassistant/components/pandora/media_player.py
@@ -8,6 +8,7 @@ import os
import re
import shutil
import signal
+from typing import cast
import pexpect
@@ -26,7 +27,7 @@ from homeassistant.const import (
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_UP,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -58,7 +59,7 @@ def setup_platform(
# Make sure we end the pandora subprocess on exit in case user doesn't
# power it down.
- def _stop_pianobar(_event):
+ def _stop_pianobar(_event: Event) -> None:
pandora.turn_off()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar)
@@ -80,7 +81,7 @@ class PandoraMediaPlayer(MediaPlayerEntity):
| MediaPlayerEntityFeature.PLAY
)
- def __init__(self, name):
+ def __init__(self, name: str) -> None:
"""Initialize the Pandora device."""
self._attr_name = name
self._attr_state = MediaPlayerState.OFF
@@ -91,13 +92,13 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._attr_source_list = []
self._time_remaining = 0
self._attr_media_duration = 0
- self._pianobar = None
+ self._pianobar: pexpect.spawn[str] | None = None
def turn_on(self) -> None:
"""Turn the media player on."""
if self.state != MediaPlayerState.OFF:
return
- self._pianobar = pexpect.spawn("pianobar")
+ self._pianobar = pexpect.spawn("pianobar", encoding="utf-8")
_LOGGER.debug("Started pianobar subprocess")
mode = self._pianobar.expect(
["Receiving new playlist", "Select station:", "Email:"]
@@ -134,8 +135,9 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._pianobar.terminate()
except pexpect.exceptions.TIMEOUT:
# kill the process group
- os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM)
- _LOGGER.debug("Killed Pianobar subprocess")
+ if (pid := self._pianobar.pid) is not None:
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
+ _LOGGER.debug("Killed Pianobar subprocess")
self._pianobar = None
self._attr_state = MediaPlayerState.OFF
self.schedule_update_ha_state()
@@ -173,13 +175,15 @@ class PandoraMediaPlayer(MediaPlayerEntity):
_LOGGER.warning("Station %s is not in list", source)
return
_LOGGER.debug("Setting station %s, %d", source, station_index)
+ assert self._pianobar is not None
self._send_station_list_command()
self._pianobar.sendline(f"{station_index}")
self._pianobar.expect("\r\n")
self._attr_state = MediaPlayerState.PLAYING
- def _send_station_list_command(self):
+ def _send_station_list_command(self) -> None:
"""Send a station list command."""
+ assert self._pianobar is not None
self._pianobar.send("s")
try:
self._pianobar.expect("Select station:", timeout=1)
@@ -189,7 +193,7 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._pianobar.send("s")
self._pianobar.expect("Select station:")
- def update_playing_status(self):
+ def update_playing_status(self) -> None:
"""Query pianobar for info about current media_title, station."""
response = self._query_for_playing_status()
if not response:
@@ -198,14 +202,15 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._update_current_song(response)
self._update_song_position()
- def _query_for_playing_status(self):
+ def _query_for_playing_status(self) -> str | None:
"""Query system for info about current track."""
+ assert self._pianobar is not None
self._clear_buffer()
self._pianobar.send("i")
try:
match_idx = self._pianobar.expect(
[
- rb"(\d\d):(\d\d)/(\d\d):(\d\d)",
+ r"(\d\d):(\d\d)/(\d\d):(\d\d)",
"No song playing",
"Select station",
"Receiving new playlist",
@@ -218,21 +223,22 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._log_match()
if match_idx == 1:
# idle.
- response = None
- elif match_idx == 2:
+ return None
+ if match_idx == 2:
# stuck on a station selection dialog. Clear it.
_LOGGER.warning("On unexpected station list page")
self._pianobar.sendcontrol("m") # press enter
self._pianobar.sendcontrol("m") # do it again b/c an 'i' got in
- response = self.update_playing_status()
- elif match_idx == 3:
+ self.update_playing_status()
+ return None
+ if match_idx == 3:
_LOGGER.debug("Received new playlist list")
- response = self.update_playing_status()
- else:
- response = self._pianobar.before.decode("utf-8")
- return response
+ self.update_playing_status()
+ return None
- def _update_current_station(self, response):
+ return self._pianobar.before
+
+ def _update_current_station(self, response: str) -> None:
"""Update current station."""
if station_match := re.search(STATION_PATTERN, response):
self._attr_source = station_match.group(1)
@@ -240,7 +246,7 @@ class PandoraMediaPlayer(MediaPlayerEntity):
else:
_LOGGER.warning("No station match")
- def _update_current_song(self, response):
+ def _update_current_song(self, response: str) -> None:
"""Update info about current song."""
if song_match := re.search(CURRENT_SONG_PATTERN, response):
(
@@ -253,19 +259,20 @@ class PandoraMediaPlayer(MediaPlayerEntity):
_LOGGER.warning("No song match")
@util.Throttle(MIN_TIME_BETWEEN_UPDATES)
- def _update_song_position(self):
+ def _update_song_position(self) -> None:
"""Get the song position and duration.
It's hard to predict whether or not the music will start during init
so we have to detect state by checking the ticker.
"""
+ assert self._pianobar is not None
(
cur_minutes,
cur_seconds,
total_minutes,
total_seconds,
- ) = self._pianobar.match.groups()
+ ) = cast(re.Match[str], self._pianobar.match).groups()
time_remaining = int(cur_minutes) * 60 + int(cur_seconds)
self._attr_media_duration = int(total_minutes) * 60 + int(total_seconds)
@@ -275,8 +282,9 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._attr_state = MediaPlayerState.PAUSED
self._time_remaining = time_remaining
- def _log_match(self):
+ def _log_match(self) -> None:
"""Log grabbed values from console."""
+ assert self._pianobar is not None
_LOGGER.debug(
"Before: %s\nMatch: %s\nAfter: %s",
repr(self._pianobar.before),
@@ -284,22 +292,25 @@ class PandoraMediaPlayer(MediaPlayerEntity):
repr(self._pianobar.after),
)
- def _send_pianobar_command(self, service_cmd):
+ def _send_pianobar_command(self, service_cmd: str) -> None:
"""Send a command to Pianobar."""
+ assert self._pianobar is not None
command = CMD_MAP.get(service_cmd)
_LOGGER.debug("Sending pinaobar command %s for %s", command, service_cmd)
if command is None:
_LOGGER.warning("Command %s not supported yet", service_cmd)
+ return
self._clear_buffer()
self._pianobar.sendline(command)
- def _update_stations(self):
+ def _update_stations(self) -> None:
"""List defined Pandora stations."""
+ assert self._pianobar is not None
self._send_station_list_command()
- station_lines = self._pianobar.before.decode("utf-8")
+ station_lines = self._pianobar.before or ""
_LOGGER.debug("Getting stations: %s", station_lines)
self._attr_source_list = []
- for line in station_lines.split("\r\n"):
+ for line in station_lines.splitlines():
if match := re.search(r"\d+\).....(.+)", line):
station = match.group(1).strip()
_LOGGER.debug("Found station %s", station)
@@ -309,12 +320,13 @@ class PandoraMediaPlayer(MediaPlayerEntity):
self._pianobar.sendcontrol("m") # press enter with blank line
self._pianobar.sendcontrol("m") # do it twice in case an 'i' got in
- def _clear_buffer(self):
+ def _clear_buffer(self) -> None:
"""Clear buffer from pexpect.
This is necessary because there are a bunch of 00:00 in the buffer
"""
+ assert self._pianobar is not None
try:
while not self._pianobar.expect(".+", timeout=0.1):
pass
@@ -324,7 +336,7 @@ class PandoraMediaPlayer(MediaPlayerEntity):
pass
-def _pianobar_exists():
+def _pianobar_exists() -> bool:
"""Verify that Pianobar is properly installed."""
pianobar_exe = shutil.which("pianobar")
if pianobar_exe:
diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py
index 89ad6066f48..db9c35a7608 100644
--- a/homeassistant/components/panel_custom/__init__.py
+++ b/homeassistant/components/panel_custom/__init__.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.components import frontend
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py
index 24248355f72..b9b42cd6ca5 100644
--- a/homeassistant/components/peblar/config_flow.py
+++ b/homeassistant/components/peblar/config_flow.py
@@ -9,7 +9,6 @@ from aiohttp import CookieJar
from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_create_clientsession
@@ -18,6 +17,7 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
@@ -27,7 +27,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _discovery_info: zeroconf.ZeroconfServiceInfo
+ _discovery_info: ZeroconfServiceInfo
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -128,7 +128,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery of a Peblar device."""
if not (sn := discovery_info.properties.get("sn")):
diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py
index 9e132da63bc..58c2fbdc899 100644
--- a/homeassistant/components/peblar/update.py
+++ b/homeassistant/components/peblar/update.py
@@ -37,14 +37,14 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = (
key="firmware",
device_class=UpdateDeviceClass.FIRMWARE,
installed_fn=lambda x: x.current.firmware,
- has_fn=lambda x: x.current.firmware is not None,
+ has_fn=lambda x: x.available.firmware is not None,
available_fn=lambda x: x.available.firmware,
),
PeblarUpdateEntityDescription(
key="customization",
translation_key="customization",
available_fn=lambda x: x.available.customization,
- has_fn=lambda x: x.current.customization is not None,
+ has_fn=lambda x: x.available.customization is not None,
installed_fn=lambda x: x.current.customization,
),
)
diff --git a/homeassistant/components/peco/manifest.json b/homeassistant/components/peco/manifest.json
index 698981e9361..7dc80c6f837 100644
--- a/homeassistant/components/peco/manifest.json
+++ b/homeassistant/components/peco/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/peco",
"iot_class": "cloud_polling",
- "requirements": ["peco==0.0.30"]
+ "requirements": ["peco==0.1.2"]
}
diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py
index 30e5f4d2a38..1c71603e41e 100644
--- a/homeassistant/components/pegel_online/__init__.py
+++ b/homeassistant/components/pegel_online/__init__.py
@@ -7,21 +7,18 @@ import logging
from aiopegelonline import PegelOnline
from aiopegelonline.const import CONNECT_ERRORS
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_STATION
-from .coordinator import PegelOnlineDataUpdateCoordinator
+from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
-type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator]
-
async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool:
"""Set up PEGELONLINE entry."""
@@ -35,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry)
except CONNECT_ERRORS as err:
raise ConfigEntryNotReady("Failed to connect") from err
- coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station)
+ coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station)
await coordinator.async_config_entry_first_refresh()
@@ -46,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: PegelOnlineConfigEntry
+) -> bool:
"""Unload PEGELONLINE entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py
index c8233673fde..1e2471a59f2 100644
--- a/homeassistant/components/pegel_online/coordinator.py
+++ b/homeassistant/components/pegel_online/coordinator.py
@@ -4,6 +4,7 @@ import logging
from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurements
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -11,12 +12,20 @@ from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES
_LOGGER = logging.getLogger(__name__)
+type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator]
+
class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements]):
"""DataUpdateCoordinator for the pegel_online integration."""
+ config_entry: PegelOnlineConfigEntry
+
def __init__(
- self, hass: HomeAssistant, name: str, api: PegelOnline, station: Station
+ self,
+ hass: HomeAssistant,
+ config_entry: PegelOnlineConfigEntry,
+ api: PegelOnline,
+ station: Station,
) -> None:
"""Initialize the PegelOnlineDataUpdateCoordinator."""
self.api = api
@@ -24,7 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements
super().__init__(
hass,
_LOGGER,
- name=name,
+ config_entry=config_entry,
+ name=config_entry.title,
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
diff --git a/homeassistant/components/pegel_online/diagnostics.py b/homeassistant/components/pegel_online/diagnostics.py
index b68437c5ee7..e3b4a166cb4 100644
--- a/homeassistant/components/pegel_online/diagnostics.py
+++ b/homeassistant/components/pegel_online/diagnostics.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
-from . import PegelOnlineConfigEntry
+from .coordinator import PegelOnlineConfigEntry
async def async_get_config_entry_diagnostics(
diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py
index 50eb80bafa8..181c0f5dc6d 100644
--- a/homeassistant/components/pegel_online/sensor.py
+++ b/homeassistant/components/pegel_online/sensor.py
@@ -16,8 +16,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import PegelOnlineConfigEntry
-from .coordinator import PegelOnlineDataUpdateCoordinator
+from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator
from .entity import PegelOnlineEntity
diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py
index d16c7e1600c..d9d89494bd9 100644
--- a/homeassistant/components/pencom/switch.py
+++ b/homeassistant/components/pencom/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py
index 07ddefa9dce..e0fb55a0363 100644
--- a/homeassistant/components/permobil/config_flow.py
+++ b/homeassistant/components/permobil/config_flow.py
@@ -17,9 +17,8 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL
from homeassistant.core import HomeAssistant, async_get_hass
-from homeassistant.helpers import selector
+from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py
index a5eb8bb4f4d..2871f4b575a 100644
--- a/homeassistant/components/persistent_notification/__init__.py
+++ b/homeassistant/components/persistent_notification/__init__.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.signal_type import SignalType
from homeassistant.util.uuid import random_uuid_hex
diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py
index 431443d9139..8e0808f9879 100644
--- a/homeassistant/components/persistent_notification/trigger.py
+++ b/homeassistant/components/persistent_notification/trigger.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py
index b793f4b33ae..856e07bb2ee 100644
--- a/homeassistant/components/person/__init__.py
+++ b/homeassistant/components/person/__init__.py
@@ -280,7 +280,7 @@ class PersonStorageCollection(collection.DictStorageCollection):
return data
@callback
- def _get_suggested_id(self, info: dict) -> str:
+ def _get_suggested_id(self, info: dict[str, str]) -> str:
"""Suggest an ID based on the config."""
return info[CONF_NAME]
diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json
index 9e1d5948a09..504be7a62dd 100644
--- a/homeassistant/components/pi_hole/strings.json
+++ b/homeassistant/components/pi_hole/strings.json
@@ -17,8 +17,8 @@
}
},
"reauth_confirm": {
- "title": "Reauthenticate PI-Hole",
- "description": "Please enter a new api key for PI-Hole at {host}/{location}",
+ "title": "Reauthenticate Pi-hole",
+ "description": "Please enter a new API key for Pi-hole at {host}/{location}",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py
index 9548029209b..4c8281f21de 100644
--- a/homeassistant/components/picnic/config_flow.py
+++ b/homeassistant/components/picnic/config_flow.py
@@ -67,8 +67,8 @@ async def validate_input(hass: HomeAssistant, data):
# Return the validation result
address = (
- f'{user_data["address"]["street"]} {user_data["address"]["house_number"]}'
- f'{user_data["address"]["house_number_ext"]}'
+ f"{user_data['address']['street']} {user_data['address']['house_number']}"
+ f"{user_data['address']['house_number_ext']}"
)
return auth_token, {
"title": address,
diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py
index c367d5ec548..b3979580990 100644
--- a/homeassistant/components/picnic/coordinator.py
+++ b/homeassistant/components/picnic/coordinator.py
@@ -79,7 +79,10 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator):
"""Get the address that identifies the Picnic service."""
if self._user_address is None:
address = self.picnic_api_client.get_user()["address"]
- self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}'
+ self._user_address = (
+ f"{address['street']} "
+ f"{address['house_number']}{address['house_number_ext']}"
+ )
return self._user_address
diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py
index c01fc00a29e..bbc775891b7 100644
--- a/homeassistant/components/picnic/services.py
+++ b/homeassistant/components/picnic/services.py
@@ -8,7 +8,7 @@ from python_picnic_api import PicnicAPI
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_AMOUNT,
diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py
index 21d5603e4c2..5f1238772b0 100644
--- a/homeassistant/components/pilight/__init__.py
+++ b/homeassistant/components/pilight/__init__.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py
index d2d83813516..fbb924d7f8f 100644
--- a/homeassistant/components/pilight/entity.py
+++ b/homeassistant/components/pilight/entity.py
@@ -10,7 +10,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN, EVENT, SERVICE_NAME
diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py
index c3d1a3c234c..9e1ecbf59d4 100644
--- a/homeassistant/components/pilight/light.py
+++ b/homeassistant/components/pilight/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_LIGHTS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py
index 5ab80f57dc6..532681e2b93 100644
--- a/homeassistant/components/pilight/sensor.py
+++ b/homeassistant/components/pilight/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py
index a1976921269..9b812075e17 100644
--- a/homeassistant/components/pilight/switch.py
+++ b/homeassistant/components/pilight/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_SWITCHES
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py
index 82ebf4532da..996faa99c5b 100644
--- a/homeassistant/components/ping/helpers.py
+++ b/homeassistant/components/ping/helpers.py
@@ -160,7 +160,7 @@ class PingDataSubProcess(PingData):
)
if pinger:
- with suppress(TypeError):
+ with suppress(TypeError, ProcessLookupError):
await pinger.kill() # type: ignore[func-returns-value]
del pinger
diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py
index 670ccffaea7..385acbe4818 100644
--- a/homeassistant/components/pioneer/media_player.py
+++ b/homeassistant/components/pioneer/media_player.py
@@ -3,9 +3,9 @@
from __future__ import annotations
import logging
-import telnetlib # pylint: disable=deprecated-module
from typing import Final
+import telnetlib # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.media_player import (
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py
index 93f8ea5ad9b..1e035205f8f 100644
--- a/homeassistant/components/pjlink/media_player.py
+++ b/homeassistant/components/pjlink/media_player.py
@@ -14,7 +14,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py
index 585b6ecfd82..6001a243a2d 100644
--- a/homeassistant/components/plaato/__init__.py
+++ b/homeassistant/components/plaato/__init__.py
@@ -32,7 +32,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py
index f398a733cd6..9adfb4a14fe 100644
--- a/homeassistant/components/plaato/config_flow.py
+++ b/homeassistant/components/plaato/config_flow.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONF_CLOUDHOOK,
diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py
index 48c606865df..27993a93779 100644
--- a/homeassistant/components/plant/__init__.py
+++ b/homeassistant/components/plant/__init__.py
@@ -32,7 +32,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py
index ae7cbb12574..3c9f35b20a4 100644
--- a/homeassistant/components/plex/config_flow.py
+++ b/homeassistant/components/plex/config_flow.py
@@ -36,9 +36,8 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import discovery_flow
+from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import (
AUTH_CALLBACK_NAME,
diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py
index eab1d086d4c..7f9c2545032 100644
--- a/homeassistant/components/plex/server.py
+++ b/homeassistant/components/plex/server.py
@@ -203,7 +203,7 @@ class PlexServer:
config_entry_update_needed = True
else:
# pylint: disable-next=raise-missing-from
- raise Unauthorized( # noqa: TRY200
+ raise Unauthorized( # noqa: B904
"New certificate cannot be validated"
" with provided token"
)
diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py
index 6114dd39a6d..a94000934eb 100644
--- a/homeassistant/components/plugwise/config_flow.py
+++ b/homeassistant/components/plugwise/config_flow.py
@@ -16,7 +16,6 @@ from plugwise.exceptions import (
)
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
ATTR_CONFIGURATION_URL,
@@ -29,6 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
DEFAULT_PORT,
@@ -105,7 +105,7 @@ async def verify_connection(
errors[CONF_BASE] = "response_error"
except UnsupportedDeviceError:
errors[CONF_BASE] = "unsupported"
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception(
"Unknown exception while verifying connection with your Plugwise Smile"
)
diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json
index 80f5be974e1..f7bd646f801 100644
--- a/homeassistant/components/plugwise/manifest.json
+++ b/homeassistant/components/plugwise/manifest.json
@@ -1,12 +1,13 @@
{
"domain": "plugwise",
"name": "Plugwise",
- "codeowners": ["@CoMPaTech", "@bouwew", "@frenck"],
+ "codeowners": ["@CoMPaTech", "@bouwew"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/plugwise",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["plugwise"],
+ "quality_scale": "platinum",
"requirements": ["plugwise==1.6.4"],
"zeroconf": ["_plugwise._tcp.local."]
}
diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml
index a7b955b4713..55abf3c330e 100644
--- a/homeassistant/components/plugwise/quality_scale.yaml
+++ b/homeassistant/components/plugwise/quality_scale.yaml
@@ -15,12 +15,8 @@ rules:
status: exempt
comment: Plugwise integration has no custom actions
common-modules: done
- docs-high-level-description:
- status: todo
- comment: Rewrite top section, docs PR prepared waiting for 36087 merge
- docs-installation-instructions:
- status: todo
- comment: Docs PR 36087
+ docs-high-level-description: done
+ docs-installation-instructions: done
docs-removal-instructions: done
docs-actions: done
brands: done
@@ -35,9 +31,7 @@ rules:
parallel-updates: done
test-coverage: done
integration-owner: done
- docs-installation-parameters:
- status: todo
- comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared)
+ docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: Plugwise has no options flow
@@ -58,25 +52,13 @@ rules:
repair-issues:
status: exempt
comment: This integration does not have repairs
- docs-use-cases:
- status: todo
- comment: Check for completeness, PR prepared waiting for 36087 merge
- docs-supported-devices:
- status: todo
- comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge
- docs-supported-functions:
- status: todo
- comment: Check for completeness, PR prepared waiting for 36087 merge
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
docs-data-update: done
- docs-known-limitations:
- status: todo
- comment: Partial in 36087 but could be more elaborate
- docs-troubleshooting:
- status: todo
- comment: Check for completeness, PR prepared waiting for 36087 merge
- docs-examples:
- status: todo
- comment: Check for completeness, PR prepared waiting for 36087 merge
+ docs-known-limitations: done
+ docs-troubleshooting: done
+ docs-examples: done
## Platinum
async-dependency: done
inject-websession: done
diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py
index a385565b837..08a3d0ab0b9 100644
--- a/homeassistant/components/plum_lightpad/light.py
+++ b/homeassistant/components/plum_lightpad/light.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import DOMAIN
diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py
index 1f6af298688..bbe75ae544c 100644
--- a/homeassistant/components/pocketcasts/sensor.py
+++ b/homeassistant/components/pocketcasts/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py
index 8f6b847fca0..4c6b0f8c6eb 100644
--- a/homeassistant/components/powerfox/diagnostics.py
+++ b/homeassistant/components/powerfox/diagnostics.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
-from powerfox import PowerMeter, WaterMeter
+from powerfox import HeatMeter, PowerMeter, WaterMeter
from homeassistant.core import HomeAssistant
@@ -52,6 +52,22 @@ async def async_get_config_entry_diagnostics(
if isinstance(coordinator.data, WaterMeter)
else {}
),
+ **(
+ {
+ "heat_meter": {
+ "outdated": coordinator.data.outdated,
+ "timestamp": datetime.strftime(
+ coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S"
+ ),
+ "total_energy": coordinator.data.total_energy,
+ "delta_energy": coordinator.data.delta_energy,
+ "total_volume": coordinator.data.total_volume,
+ "delta_volume": coordinator.data.delta_volume,
+ }
+ }
+ if isinstance(coordinator.data, HeatMeter)
+ else {}
+ ),
}
for coordinator in powerfox_data
],
diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py
index 7771f96dd81..6505139fcd9 100644
--- a/homeassistant/components/powerfox/sensor.py
+++ b/homeassistant/components/powerfox/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from powerfox import Device, PowerMeter, WaterMeter
+from powerfox import Device, HeatMeter, PowerMeter, WaterMeter
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -23,7 +23,7 @@ from .entity import PowerfoxEntity
@dataclass(frozen=True, kw_only=True)
-class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter)](
+class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter, HeatMeter)](
SensorEntityDescription
):
"""Describes Poweropti sensor entity."""
@@ -93,6 +93,40 @@ SENSORS_WATER: tuple[PowerfoxSensorEntityDescription[WaterMeter], ...] = (
),
)
+SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = (
+ PowerfoxSensorEntityDescription[HeatMeter](
+ key="heat_total_energy",
+ translation_key="heat_total_energy",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.total_energy,
+ ),
+ PowerfoxSensorEntityDescription[HeatMeter](
+ key="heat_delta_energy",
+ translation_key="heat_delta_energy",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ value_fn=lambda meter: meter.delta_energy,
+ ),
+ PowerfoxSensorEntityDescription[HeatMeter](
+ key="heat_total_volume",
+ translation_key="heat_total_volume",
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ device_class=SensorDeviceClass.WATER,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.total_volume,
+ ),
+ PowerfoxSensorEntityDescription[HeatMeter](
+ key="heat_delta_volume",
+ translation_key="heat_delta_volume",
+ suggested_display_precision=2,
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ device_class=SensorDeviceClass.WATER,
+ value_fn=lambda meter: meter.delta_volume,
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -121,6 +155,15 @@ async def async_setup_entry(
)
for description in SENSORS_WATER
)
+ if isinstance(coordinator.data, HeatMeter):
+ entities.extend(
+ PowerfoxSensorEntity(
+ coordinator=coordinator,
+ description=description,
+ device=coordinator.device,
+ )
+ for description in SENSORS_HEAT
+ )
async_add_entities(entities)
diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json
index 4a7c8e8fa4d..cb068a212c2 100644
--- a/homeassistant/components/powerfox/strings.json
+++ b/homeassistant/components/powerfox/strings.json
@@ -64,6 +64,18 @@
},
"warm_water": {
"name": "Warm water"
+ },
+ "heat_total_energy": {
+ "name": "Total energy"
+ },
+ "heat_delta_energy": {
+ "name": "Delta energy"
+ },
+ "heat_total_volume": {
+ "name": "Total volume"
+ },
+ "heat_delta_volume": {
+ "name": "Delta volume"
}
}
}
diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py
index 6a2522ac43b..d84452c0443 100644
--- a/homeassistant/components/powerwall/__init__.py
+++ b/homeassistant/components/powerwall/__init__.py
@@ -14,6 +14,7 @@ from tesla_powerwall import (
Powerwall,
PowerwallUnreachableError,
)
+from yarl import URL
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
@@ -25,7 +26,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.network import is_ip_address
-from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL
+from .const import (
+ AUTH_COOKIE_KEY,
+ CONFIG_ENTRY_COOKIE,
+ DOMAIN,
+ POWERWALL_API_CHANGED,
+ POWERWALL_COORDINATOR,
+ UPDATE_INTERVAL,
+)
from .models import (
PowerwallBaseInfo,
PowerwallConfigEntry,
@@ -52,6 +60,8 @@ class PowerwallDataManager:
self,
hass: HomeAssistant,
power_wall: Powerwall,
+ cookie_jar: CookieJar,
+ entry: PowerwallConfigEntry,
ip_address: str,
password: str | None,
runtime_data: PowerwallRuntimeData,
@@ -62,6 +72,8 @@ class PowerwallDataManager:
self.password = password
self.runtime_data = runtime_data
self.power_wall = power_wall
+ self.cookie_jar = cookie_jar
+ self.entry = entry
@property
def api_changed(self) -> int:
@@ -72,7 +84,9 @@ class PowerwallDataManager:
"""Recreate the login on auth failure."""
if self.power_wall.is_authenticated():
await self.power_wall.logout()
+ # Always use the password when recreating the login
await self.power_wall.login(self.password or "")
+ self.save_auth_cookie()
async def async_update_data(self) -> PowerwallData:
"""Fetch data from API endpoint."""
@@ -116,41 +130,74 @@ class PowerwallDataManager:
return data
raise RuntimeError("unreachable")
+ @callback
+ def save_auth_cookie(self) -> None:
+ """Save the auth cookie."""
+ for cookie in self.cookie_jar:
+ if cookie.key == AUTH_COOKIE_KEY:
+ self.hass.config_entries.async_update_entry(
+ self.entry,
+ data={**self.entry.data, CONFIG_ENTRY_COOKIE: cookie.value},
+ )
+ _LOGGER.debug("Saved auth cookie")
+ break
+
async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> bool:
"""Set up Tesla Powerwall from a config entry."""
ip_address: str = entry.data[CONF_IP_ADDRESS]
password: str | None = entry.data.get(CONF_PASSWORD)
+
+ cookie_jar: CookieJar = CookieJar(unsafe=True)
+ use_auth_cookie: bool = False
+ # Try to reuse the auth cookie
+ auth_cookie_value: str | None = entry.data.get(CONFIG_ENTRY_COOKIE)
+ if auth_cookie_value:
+ cookie_jar.update_cookies(
+ {AUTH_COOKIE_KEY: auth_cookie_value},
+ URL(f"http://{ip_address}"),
+ )
+ _LOGGER.debug("Using existing auth cookie")
+ use_auth_cookie = True
+
http_session = async_create_clientsession(
- hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
+ hass, verify_ssl=False, cookie_jar=cookie_jar
)
async with AsyncExitStack() as stack:
power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False)
stack.push_async_callback(power_wall.close)
- try:
- base_info = await _login_and_fetch_base_info(
- power_wall, ip_address, password
- )
+ for tries in range(2):
+ try:
+ base_info = await _login_and_fetch_base_info(
+ power_wall, ip_address, password, use_auth_cookie
+ )
- # Cancel closing power_wall on success
- stack.pop_all()
- except (TimeoutError, PowerwallUnreachableError) as err:
- raise ConfigEntryNotReady from err
- except MissingAttributeError as err:
- # The error might include some important information about what exactly changed.
- _LOGGER.error("The powerwall api has changed: %s", str(err))
- persistent_notification.async_create(
- hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE
- )
- return False
- except AccessDeniedError as err:
- _LOGGER.debug("Authentication failed", exc_info=err)
- raise ConfigEntryAuthFailed from err
- except ApiError as err:
- raise ConfigEntryNotReady from err
+ # Cancel closing power_wall on success
+ stack.pop_all()
+ break
+ except (TimeoutError, PowerwallUnreachableError) as err:
+ raise ConfigEntryNotReady from err
+ except MissingAttributeError as err:
+ # The error might include some important information about what exactly changed.
+ _LOGGER.error("The powerwall api has changed: %s", str(err))
+ persistent_notification.async_create(
+ hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE
+ )
+ return False
+ except AccessDeniedError as err:
+ if use_auth_cookie and tries == 0:
+ _LOGGER.debug(
+ "Authentication failed with cookie, retrying with password"
+ )
+ use_auth_cookie = False
+ continue
+ _LOGGER.debug("Authentication failed", exc_info=err)
+ raise ConfigEntryAuthFailed from err
+ except ApiError as err:
+ raise ConfigEntryNotReady from err
gateway_din = base_info.gateway_din
if entry.unique_id is not None and is_ip_address(entry.unique_id):
@@ -163,7 +210,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) ->
api_instance=power_wall,
)
- manager = PowerwallDataManager(hass, power_wall, ip_address, password, runtime_data)
+ manager = PowerwallDataManager(
+ hass,
+ power_wall,
+ cookie_jar,
+ entry,
+ ip_address,
+ password,
+ runtime_data,
+ )
+ manager.save_auth_cookie()
coordinator = DataUpdateCoordinator(
hass,
@@ -213,10 +269,11 @@ async def async_migrate_entity_unique_ids(
async def _login_and_fetch_base_info(
- power_wall: Powerwall, host: str, password: str | None
+ power_wall: Powerwall, host: str, password: str | None, use_auth_cookie: bool
) -> PowerwallBaseInfo:
"""Login to the powerwall and fetch the base info."""
- if password is not None:
+ # Login step is skipped if password is None or if we are using the auth cookie
+ if not (password is None or use_auth_cookie):
await power_wall.login(password)
return await _call_base_info(power_wall, host)
diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py
index 0c39392ca19..b082016e562 100644
--- a/homeassistant/components/powerwall/config_flow.py
+++ b/homeassistant/components/powerwall/config_flow.py
@@ -17,7 +17,6 @@ from tesla_powerwall import (
)
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
@@ -28,10 +27,11 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.util.network import is_ip_address
from . import async_last_update_was_successful
-from .const import DOMAIN
+from .const import CONFIG_ENTRY_COOKIE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -116,7 +116,7 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
) and not await _powerwall_is_reachable(ip_address, password)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
self.ip_address = discovery_info.ip
@@ -257,8 +257,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
{CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input}
)
if not errors:
+ # We have a new valid connection, old cookie is no longer valid
+ user_input[CONFIG_ENTRY_COOKIE] = None
return self.async_update_reload_and_abort(
- reauth_entry, data_updates=user_input
+ reauth_entry, data_updates={**user_input, CONFIG_ENTRY_COOKIE: None}
)
self.context["title_placeholders"] = {
diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py
index bb3a6c2355e..186a1221a87 100644
--- a/homeassistant/components/powerwall/const.py
+++ b/homeassistant/components/powerwall/const.py
@@ -18,3 +18,6 @@ ATTR_IS_ACTIVE = "is_active"
MODEL = "PowerWall 2"
MANUFACTURER = "Tesla"
+
+CONFIG_ENTRY_COOKIE = "cookie"
+AUTH_COOKIE_KEY = "AuthCookie"
diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py
index c7311e8691b..90340bc70fa 100644
--- a/homeassistant/components/private_ble_device/config_flow.py
+++ b/homeassistant/components/private_ble_device/config_flow.py
@@ -20,8 +20,7 @@ CONF_IRK = "irk"
def _parse_irk(irk: str) -> bytes | None:
- if irk.startswith("irk:"):
- irk = irk[4:]
+ irk = irk.removeprefix("irk:")
if irk.endswith("="):
try:
diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json
index 6759cdda0f0..445affbcd57 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.20.0"]
+ "requirements": ["bluetooth-data-tools==1.23.4"]
}
diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py
index 389e3384ad9..04dc6d76a5e 100644
--- a/homeassistant/components/profiler/__init__.py
+++ b/homeassistant/components/profiler/__init__.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import async_register_admin_service
@@ -436,10 +436,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall)
# Imports deferred to avoid loading modules
# in memory since usually only one part of this
# integration is used at a time
- if sys.version_info >= (3, 13):
- raise HomeAssistantError(
- "Memory profiling is not supported on Python 3.13. Please use Python 3.12."
- )
from guppy import hpy # pylint: disable=import-outside-toplevel
start_time = int(time.time() * 1000000)
diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json
index 8d2814c8c7f..814b00a16d4 100644
--- a/homeassistant/components/profiler/manifest.json
+++ b/homeassistant/components/profiler/manifest.json
@@ -7,7 +7,7 @@
"quality_scale": "internal",
"requirements": [
"pyprof2calltree==1.4.5",
- "guppy3==3.1.4.post1;python_version<'3.13'",
+ "guppy3==3.1.5",
"objgraph==3.5.0"
],
"single_config_entry": true
diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py
index 1bf23befbdb..4d090f4d0c1 100644
--- a/homeassistant/components/progettihwsw/__init__.py
+++ b/homeassistant/components/progettihwsw/__init__.py
@@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up ProgettiHWSW Automation from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI(
- f'{entry.data["host"]}:{entry.data["port"]}'
+ f"{entry.data['host']}:{entry.data['port']}"
)
# Check board validation again to load new values to API.
diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py
index 2202678da9b..2e5ea221dca 100644
--- a/homeassistant/components/progettihwsw/config_flow.py
+++ b/homeassistant/components/progettihwsw/config_flow.py
@@ -19,7 +19,7 @@ DATA_SCHEMA = vol.Schema(
async def validate_input(hass: HomeAssistant, data):
"""Validate the user host input."""
- api_instance = ProgettiHWSWAPI(f'{data["host"]}:{data["port"]}')
+ api_instance = ProgettiHWSWAPI(f"{data['host']}:{data['port']}")
is_valid = await api_instance.check_board()
if not is_valid:
diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py
index be7d394993a..03f53dec390 100644
--- a/homeassistant/components/proliphix/climate.py
+++ b/homeassistant/components/proliphix/climate.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py
index ab012847bba..3adc33e9935 100644
--- a/homeassistant/components/prometheus/__init__.py
+++ b/homeassistant/components/prometheus/__init__.py
@@ -63,8 +63,11 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
-from homeassistant.helpers import entityfilter, state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ entityfilter,
+ state as state_helper,
+)
from homeassistant.helpers.entity_registry import (
EVENT_ENTITY_REGISTRY_UPDATED,
EventEntityRegistryUpdatedData,
diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py
index 1118e747275..e9d2bbde4e5 100644
--- a/homeassistant/components/prowl/notify.py
+++ b/homeassistant/components/prowl/notify.py
@@ -17,8 +17,8 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py
index 763274243c5..2338464558d 100644
--- a/homeassistant/components/proximity/__init__.py
+++ b/homeassistant/components/proximity/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import logging
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import (
@@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) ->
"""Set up Proximity from a config entry."""
_LOGGER.debug("setup %s with config:%s", entry.title, entry.data)
- coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data))
+ coordinator = ProximityDataUpdateCoordinator(hass, entry)
entry.async_on_unload(
async_track_state_change_event(
@@ -48,11 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) ->
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR])
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(
+ hass: HomeAssistant, entry: ProximityConfigEntry
+) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py
index a8dd85c1523..055c15125f1 100644
--- a/homeassistant/components/proximity/coordinator.py
+++ b/homeassistant/components/proximity/coordinator.py
@@ -23,7 +23,6 @@ from homeassistant.core import (
)
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.location import distance
@@ -75,16 +74,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
config_entry: ProximityConfigEntry
- def __init__(
- self, hass: HomeAssistant, friendly_name: str, config: ConfigType
- ) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: ProximityConfigEntry) -> None:
"""Initialize the Proximity coordinator."""
- self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES]
- self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES]
- self.tolerance: int = config[CONF_TOLERANCE]
- self.proximity_zone_id: str = config[CONF_ZONE]
+ self.ignored_zone_ids: list[str] = config_entry.data[CONF_IGNORED_ZONES]
+ self.tracked_entities: list[str] = config_entry.data[CONF_TRACKED_ENTITIES]
+ self.tolerance: int = config_entry.data[CONF_TOLERANCE]
+ self.proximity_zone_id: str = config_entry.data[CONF_ZONE]
self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1]
- self.unit_of_measurement: str = config.get(
+ self.unit_of_measurement: str = config_entry.data.get(
CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit
)
self.entity_mapping: dict[str, list[str]] = defaultdict(list)
@@ -92,7 +89,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
super().__init__(
hass,
_LOGGER,
- name=friendly_name,
+ config_entry=config_entry,
+ name=config_entry.title,
update_interval=None,
)
diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py
index 6d6771debc4..0db6ea28652 100644
--- a/homeassistant/components/proxmoxve/__init__.py
+++ b/homeassistant/components/proxmoxve/__init__.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py
index e5e3d01591a..f6e909f13d1 100644
--- a/homeassistant/components/proxy/camera.py
+++ b/homeassistant/components/proxy/camera.py
@@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json
index e73eddf3cdd..6925b9e2133 100644
--- a/homeassistant/components/proxy/manifest.json
+++ b/homeassistant/components/proxy/manifest.json
@@ -4,5 +4,5 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/proxy",
"quality_scale": "legacy",
- "requirements": ["Pillow==11.0.0"]
+ "requirements": ["Pillow==11.1.0"]
}
diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py
index 0ada2885fa7..2ccf086071a 100644
--- a/homeassistant/components/ps4/__init__.py
+++ b/homeassistant/components/ps4/__init__.py
@@ -28,7 +28,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType
-from homeassistant.util import location
+from homeassistant.util import location as location_util
from homeassistant.util.json import JsonObjectType, load_json_object
from .config_flow import PlayStation4FlowHandler # noqa: F401
@@ -103,7 +103,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Migrate Version 1 -> Version 2: New region codes.
if version == 1:
- loc = await location.async_detect_location_info(async_get_clientsession(hass))
+ loc = await location_util.async_detect_location_info(
+ async_get_clientsession(hass)
+ )
if loc:
country = COUNTRYCODE_NAMES.get(loc.country_code)
if country in COUNTRIES:
diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py
index 877fb595fc0..4e3f8f08e39 100644
--- a/homeassistant/components/ps4/config_flow.py
+++ b/homeassistant/components/ps4/config_flow.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
CONF_TOKEN,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.util import location
+from homeassistant.util import location as location_util
from .const import (
CONFIG_ENTRY_VERSION,
@@ -54,7 +54,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN):
self.region = None
self.pin: str | None = None
self.m_device = None
- self.location: location.LocationInfo | None = None
+ self.location: location_util.LocationInfo | None = None
self.device_list: list[str] = []
async def async_step_user(
@@ -190,7 +190,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN):
# Try to find region automatically.
if not self.location:
- self.location = await location.async_detect_location_info(
+ self.location = await location_util.async_detect_location_info(
async_get_clientsession(self.hass)
)
if self.location:
diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py
index 4ab1f905068..1974363a8e3 100644
--- a/homeassistant/components/pulseaudio_loopback/switch.py
+++ b/homeassistant/components/pulseaudio_loopback/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py
index a2bbb671ff7..0dcb1a9ab13 100644
--- a/homeassistant/components/pure_energie/config_flow.py
+++ b/homeassistant/components/pure_energie/config_flow.py
@@ -7,11 +7,11 @@ from typing import Any
from gridnet import Device, GridNet, GridNetConnectionError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import TextSelector
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -58,7 +58,7 @@ class PureEnergieFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.discovered_host = discovery_info.host
diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json
index b082e088ba2..006093f3545 100644
--- a/homeassistant/components/purpleair/strings.json
+++ b/homeassistant/components/purpleair/strings.json
@@ -6,7 +6,7 @@
"data": {
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
- "distance": "Search Radius"
+ "distance": "Search radius"
},
"data_description": {
"latitude": "The latitude around which to search for sensors",
@@ -53,7 +53,7 @@
"options": {
"step": {
"add_sensor": {
- "title": "Add Sensor",
+ "title": "Add sensor",
"description": "[%key:component::purpleair::config::step::by_coordinates::description%]",
"data": {
"latitude": "[%key:common::config_flow::data::latitude%]",
@@ -67,7 +67,7 @@
}
},
"choose_sensor": {
- "title": "Choose Sensor to Add",
+ "title": "Choose sensor to add",
"description": "[%key:component::purpleair::config::step::choose_sensor::description%]",
"data": {
"sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]"
@@ -84,9 +84,9 @@
}
},
"remove_sensor": {
- "title": "Remove Sensor",
+ "title": "Remove sensor",
"data": {
- "sensor_device_id": "Sensor Name"
+ "sensor_device_id": "Sensor name"
},
"data_description": {
"sensor_device_id": "The sensor to remove"
diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py
index 37ac6144d0d..603fe89d542 100644
--- a/homeassistant/components/push/camera.py
+++ b/homeassistant/components/push/camera.py
@@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py
index b5c517c8662..faca654b420 100644
--- a/homeassistant/components/pushsafer/notify.py
+++ b/homeassistant/components/pushsafer/notify.py
@@ -21,7 +21,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py
index 6327164e3c8..4d120e9fae7 100644
--- a/homeassistant/components/pvpc_hourly_pricing/__init__.py
+++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py
@@ -3,7 +3,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN
from .coordinator import ElecPricesDataUpdateCoordinator
diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py
index 8b85dfa29a4..f07db509630 100644
--- a/homeassistant/components/pyload/__init__.py
+++ b/homeassistant/components/pyload/__init__.py
@@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo
"""Set up pyLoad from a config entry."""
url = (
- f"{"https" if entry.data[CONF_SSL] else "http"}://"
+ f"{'https' if entry.data[CONF_SSL] else 'http'}://"
f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/"
)
diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py
index 3e6cbd33bb3..b9bfc579cfc 100644
--- a/homeassistant/components/pyload/config_flow.py
+++ b/homeassistant/components/pyload/config_flow.py
@@ -22,15 +22,15 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
-from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
+from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -83,7 +83,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non
)
url = (
- f"{"https" if user_input[CONF_SSL] else "http"}://"
+ f"{'https' if user_input[CONF_SSL] else 'http'}://"
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/"
)
pyload = PyLoadAPI(
@@ -120,7 +120,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- title = user_input.pop(CONF_NAME, DEFAULT_NAME)
+ title = DEFAULT_NAME
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
@@ -131,25 +131,6 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import config from yaml."""
-
- config = {
- CONF_NAME: import_data.get(CONF_NAME),
- CONF_HOST: import_data.get(CONF_HOST, DEFAULT_HOST),
- CONF_PASSWORD: import_data.get(CONF_PASSWORD, ""),
- CONF_PORT: import_data.get(CONF_PORT, DEFAULT_PORT),
- CONF_SSL: import_data.get(CONF_SSL, False),
- CONF_USERNAME: import_data.get(CONF_USERNAME, ""),
- CONF_VERIFY_SSL: False,
- }
-
- result = await self.async_step_user(config)
-
- if errors := result.get("errors"):
- return self.async_abort(reason=errors["base"])
- return result
-
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
diff --git a/homeassistant/components/pyload/const.py b/homeassistant/components/pyload/const.py
index a0b66687bd0..e802152df16 100644
--- a/homeassistant/components/pyload/const.py
+++ b/homeassistant/components/pyload/const.py
@@ -2,12 +2,9 @@
DOMAIN = "pyload"
-DEFAULT_HOST = "localhost"
DEFAULT_NAME = "pyLoad"
DEFAULT_PORT = 8000
-ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=pyload"}
-
MANUFACTURER = "pyLoad Team"
SERVICE_NAME = "pyLoad"
diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py
index a1b29b46260..38f681d30d5 100644
--- a/homeassistant/components/pyload/sensor.py
+++ b/homeassistant/components/pyload/sensor.py
@@ -6,43 +6,19 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
-import voluptuous as vol
-
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import (
- CONF_HOST,
- CONF_MONITORED_VARIABLES,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_PORT,
- CONF_SSL,
- CONF_USERNAME,
- UnitOfDataRate,
- UnitOfInformation,
-)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
-import homeassistant.helpers.config_validation as cv
+from homeassistant.const import UnitOfDataRate, UnitOfInformation
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
+from homeassistant.helpers.typing import StateType
from . import PyLoadConfigEntry
-from .const import (
- DEFAULT_HOST,
- DEFAULT_NAME,
- DEFAULT_PORT,
- DOMAIN,
- ISSUE_PLACEHOLDER,
- UNIT_DOWNLOADS,
-)
+from .const import UNIT_DOWNLOADS
from .coordinator import PyLoadData
from .entity import BasePyLoadEntity
@@ -106,63 +82,6 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSensorEntityDescription, ...] = (
),
)
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_MONITORED_VARIABLES, default=["speed"]): vol.All(
- cv.ensure_list, [vol.In(PyLoadSensorEntity)]
- ),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_SSL, default=False): cv.boolean,
- vol.Optional(CONF_USERNAME): cv.string,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Import config from yaml."""
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config
- )
- if (
- result.get("type") == FlowResultType.CREATE_ENTRY
- or result.get("reason") == "already_configured"
- ):
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- is_fixable=False,
- issue_domain=DOMAIN,
- breaks_in_ha_version="2025.1.0",
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "pyLoad",
- },
- )
- elif error := result.get("reason"):
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{error}",
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{error}",
- translation_placeholders=ISSUE_PLACEHOLDER,
- )
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json
index 4ae4c4fee67..0fd9b4befcf 100644
--- a/homeassistant/components/pyload/strings.json
+++ b/homeassistant/components/pyload/strings.json
@@ -105,19 +105,5 @@
"service_call_auth_exception": {
"message": "Unable to send command to pyLoad due to an authentication error, try again later"
}
- },
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The pyLoad YAML configuration import failed",
- "description": "Configuring pyLoad 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 pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_invalid_auth": {
- "title": "The pyLoad YAML configuration import failed",
- "description": "Configuring pyLoad 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 pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_unknown": {
- "title": "The pyLoad YAML configuration import failed",
- "description": "Configuring pyLoad 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 pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- }
}
}
diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py
index a45107181de..0729d73a034 100644
--- a/homeassistant/components/python_script/__init__.py
+++ b/homeassistant/components/python_script/__init__.py
@@ -1,6 +1,6 @@
"""Component to allow running Python scripts."""
-from collections.abc import Mapping, Sequence
+from collections.abc import Callable, Mapping, Sequence
import datetime
import glob
import logging
@@ -36,8 +36,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
-from homeassistant.util import raise_if_invalid_filename
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, raise_if_invalid_filename
from homeassistant.util.yaml.loader import load_yaml_dict
_LOGGER = logging.getLogger(__name__)
@@ -197,7 +196,12 @@ def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any:
@bind_hass
-def execute_script(hass, name, data=None, return_response=False):
+def execute_script(
+ hass: HomeAssistant,
+ name: str,
+ data: dict[str, Any] | None = None,
+ return_response: bool = False,
+) -> dict | None:
"""Execute a script."""
filename = f"{name}.py"
raise_if_invalid_filename(filename)
@@ -207,7 +211,13 @@ def execute_script(hass, name, data=None, return_response=False):
@bind_hass
-def execute(hass, filename, source, data=None, return_response=False):
+def execute(
+ hass: HomeAssistant,
+ filename: str,
+ source: Any,
+ data: dict[str, Any] | None = None,
+ return_response: bool = False,
+) -> dict | None:
"""Execute Python source."""
compiled = compile_restricted_exec(source, filename=filename)
@@ -223,25 +233,18 @@ def execute(hass, filename, source, data=None, return_response=False):
"Warning loading script %s: %s", filename, ", ".join(compiled.warnings)
)
- def protected_getattr(obj, name, default=None):
+ def protected_getattr(obj: object, name: str, default: Any = None) -> Any:
"""Restricted method to get attributes."""
if name.startswith("async_"):
raise ScriptError("Not allowed to access async methods")
if (
- obj is hass
- and name not in ALLOWED_HASS
- or obj is hass.bus
- and name not in ALLOWED_EVENTBUS
- or obj is hass.states
- and name not in ALLOWED_STATEMACHINE
- or obj is hass.services
- and name not in ALLOWED_SERVICEREGISTRY
- or obj is dt_util
- and name not in ALLOWED_DT_UTIL
- or obj is datetime
- and name not in ALLOWED_DATETIME
- or isinstance(obj, TimeWrapper)
- and name not in ALLOWED_TIME
+ (obj is hass and name not in ALLOWED_HASS)
+ or (obj is hass.bus and name not in ALLOWED_EVENTBUS)
+ or (obj is hass.states and name not in ALLOWED_STATEMACHINE)
+ or (obj is hass.services and name not in ALLOWED_SERVICEREGISTRY)
+ or (obj is dt_util and name not in ALLOWED_DT_UTIL)
+ or (obj is datetime and name not in ALLOWED_DATETIME)
+ or (isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME)
):
raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}")
@@ -316,10 +319,10 @@ def execute(hass, filename, source, data=None, return_response=False):
class StubPrinter:
"""Class to handle printing inside scripts."""
- def __init__(self, _getattr_):
+ def __init__(self, _getattr_: Callable) -> None:
"""Initialize our printer."""
- def _call_print(self, *objects, **kwargs):
+ def _call_print(self, *objects: object, **kwargs: Any) -> None:
"""Print text."""
_LOGGER.warning("Don't use print() inside scripts. Use logger.info() instead")
@@ -330,7 +333,7 @@ class TimeWrapper:
# Class variable, only going to warn once per Home Assistant run
warned = False
- def sleep(self, *args, **kwargs):
+ def sleep(self, *args: Any, **kwargs: Any) -> None:
"""Sleep method that warns once."""
if not TimeWrapper.warned:
TimeWrapper.warned = True
@@ -340,12 +343,12 @@ class TimeWrapper:
time.sleep(*args, **kwargs)
- def __getattr__(self, attr):
+ def __getattr__(self, attr: str) -> Any:
"""Fetch an attribute from Time module."""
attribute = getattr(time, attr)
if callable(attribute):
- def wrapper(*args, **kw):
+ def wrapper(*args: Any, **kw: Any) -> Any:
"""Wrap to return callable method if callable."""
return attribute(*args, **kw)
diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json
index 4348fdd9911..c8cb1da40c9 100644
--- a/homeassistant/components/python_script/manifest.json
+++ b/homeassistant/components/python_script/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/python_script",
"loggers": ["RestrictedPython"],
"quality_scale": "internal",
- "requirements": ["RestrictedPython==7.4"]
+ "requirements": ["RestrictedPython==8.0"]
}
diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py
new file mode 100644
index 00000000000..da9dcfe69be
--- /dev/null
+++ b/homeassistant/components/qbus/__init__.py
@@ -0,0 +1,87 @@
+"""The Qbus integration."""
+
+import logging
+
+from homeassistant.components.mqtt import async_wait_for_mqtt_client
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN, PLATFORMS
+from .coordinator import (
+ QBUS_KEY,
+ QbusConfigCoordinator,
+ QbusConfigEntry,
+ QbusControllerCoordinator,
+)
+
+_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Qbus integration.
+
+ We set up a single coordinator for managing Qbus config updates. The
+ config update contains the configuration for all controllers (and
+ config entries). This avoids having each device requesting and managing
+ the config on its own.
+ """
+ _LOGGER.debug("Loading integration")
+
+ if not await async_wait_for_mqtt_client(hass):
+ _LOGGER.error("MQTT integration not available")
+ return False
+
+ config_coordinator = QbusConfigCoordinator.get_or_create(hass)
+ await config_coordinator.async_subscribe_to_config()
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool:
+ """Set up Qbus from a config entry."""
+ _LOGGER.debug("%s - Loading entry", entry.unique_id)
+
+ if not await async_wait_for_mqtt_client(hass):
+ _LOGGER.error("MQTT integration not available")
+ raise ConfigEntryNotReady("MQTT integration not available")
+
+ coordinator = QbusControllerCoordinator(hass, entry)
+ entry.runtime_data = coordinator
+
+ await coordinator.async_config_entry_first_refresh()
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ # Get current config
+ config = await QbusConfigCoordinator.get_or_create(
+ hass
+ ).async_get_or_request_config()
+
+ # Update the controller config
+ if config:
+ await coordinator.async_update_controller_config(config)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool:
+ """Unload a config entry."""
+ _LOGGER.debug("%s - Unloading entry", entry.unique_id)
+
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ entry.runtime_data.shutdown()
+ cleanup(hass, entry)
+
+ return unload_ok
+
+
+def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
+ """Shutdown if no more entries are loaded."""
+ entries = hass.config_entries.async_loaded_entries(DOMAIN)
+ count = len(entries)
+
+ # During unloading of the entry, it is not marked as unloaded yet. So
+ # count can be 1 if it is the last one.
+ if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)):
+ config_coordinator.shutdown()
diff --git a/homeassistant/components/qbus/config_flow.py b/homeassistant/components/qbus/config_flow.py
new file mode 100644
index 00000000000..2f08c5b47e2
--- /dev/null
+++ b/homeassistant/components/qbus/config_flow.py
@@ -0,0 +1,160 @@
+"""Config flow for Qbus."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+from qbusmqttapi.discovery import QbusMqttDevice
+from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
+
+from homeassistant.components.mqtt import client as mqtt
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ID
+from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
+
+from .const import CONF_SERIAL_NUMBER, DOMAIN
+from .coordinator import QbusConfigCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class QbusFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle Qbus config flow."""
+
+ VERSION = 1
+
+ def __init__(self) -> None:
+ """Initialize."""
+ self._message_factory = QbusMqttMessageFactory()
+ self._topic_factory = QbusMqttTopicFactory()
+
+ self._gateway_topic = self._topic_factory.get_gateway_state_topic()
+ self._config_topic = self._topic_factory.get_config_topic()
+ self._device_topic = self._topic_factory.get_device_state_topic("+")
+
+ self._device: QbusMqttDevice | None = None
+
+ async def async_step_mqtt(
+ self, discovery_info: MqttServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by MQTT discovery."""
+ _LOGGER.debug("Running mqtt discovery for topic %s", discovery_info.topic)
+
+ # Abort if the payload is empty
+ if not discovery_info.payload:
+ _LOGGER.debug("Payload empty")
+ return self.async_abort(reason="invalid_discovery_info")
+
+ match discovery_info.subscribed_topic:
+ case self._gateway_topic:
+ return await self._async_handle_gateway_topic(discovery_info)
+
+ case self._config_topic:
+ return await self._async_handle_config_topic(discovery_info)
+
+ case self._device_topic:
+ return await self._async_handle_device_topic(discovery_info)
+
+ return self.async_abort(reason="invalid_discovery_info")
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm the setup."""
+ if TYPE_CHECKING:
+ assert self._device is not None
+
+ if user_input is not None:
+ return self.async_create_entry(
+ title=f"Controller {self._device.serial_number}",
+ data={
+ CONF_SERIAL_NUMBER: self._device.serial_number,
+ CONF_ID: self._device.id,
+ },
+ )
+
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={
+ CONF_SERIAL_NUMBER: self._device.serial_number,
+ },
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ return self.async_abort(reason="not_supported")
+
+ async def _async_handle_gateway_topic(
+ self, discovery_info: MqttServiceInfo
+ ) -> ConfigFlowResult:
+ _LOGGER.debug("Handling gateway state")
+ gateway_state = self._message_factory.parse_gateway_state(
+ discovery_info.payload
+ )
+
+ if gateway_state is not None and gateway_state.online is True:
+ _LOGGER.debug("Requesting config")
+ await mqtt.async_publish(
+ self.hass, self._topic_factory.get_get_config_topic(), b""
+ )
+
+ # Abort to wait for config topic
+ return self.async_abort(reason="discovery_in_progress")
+
+ async def _async_handle_config_topic(
+ self, discovery_info: MqttServiceInfo
+ ) -> ConfigFlowResult:
+ _LOGGER.debug("Handling config topic")
+ qbus_config = self._message_factory.parse_discovery(discovery_info.payload)
+
+ if qbus_config is not None:
+ QbusConfigCoordinator.get_or_create(self.hass).store_config(qbus_config)
+
+ _LOGGER.debug("Requesting device states")
+ device_ids = [x.id for x in qbus_config.devices]
+ request = self._message_factory.create_state_request(device_ids)
+ await mqtt.async_publish(self.hass, request.topic, request.payload)
+
+ # Abort to wait for device topic
+ return self.async_abort(reason="discovery_in_progress")
+
+ async def _async_handle_device_topic(
+ self, discovery_info: MqttServiceInfo
+ ) -> ConfigFlowResult:
+ _LOGGER.debug("Discovering device")
+ qbus_config = await QbusConfigCoordinator.get_or_create(
+ self.hass
+ ).async_get_or_request_config()
+
+ if qbus_config is None:
+ _LOGGER.error("Qbus config not ready")
+ return self.async_abort(reason="invalid_discovery_info")
+
+ device_id = discovery_info.topic.split("/")[2]
+ self._device = qbus_config.get_device_by_id(device_id)
+
+ if self._device is None:
+ _LOGGER.warning("Device with id '%s' not found in config", device_id)
+ return self.async_abort(reason="invalid_discovery_info")
+
+ await self.async_set_unique_id(self._device.serial_number)
+
+ # Do not use error message "already_configured" (which is the
+ # default), as this will result in unsubscribing from the triggered
+ # mqtt topic. The topic subscribed to has a wildcard to allow
+ # discovery of multiple devices. Unsubscribing would result in
+ # not discovering new or unconfigured devices.
+ self._abort_if_unique_id_configured(error="device_already_configured")
+
+ self.context.update(
+ {
+ "title_placeholders": {
+ CONF_SERIAL_NUMBER: self._device.serial_number,
+ }
+ }
+ )
+
+ return await self.async_step_discovery_confirm()
diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py
new file mode 100644
index 00000000000..ddfb8963cb7
--- /dev/null
+++ b/homeassistant/components/qbus/const.py
@@ -0,0 +1,12 @@
+"""Constants for the Qbus integration."""
+
+from typing import Final
+
+from homeassistant.const import Platform
+
+DOMAIN: Final = "qbus"
+PLATFORMS: list[Platform] = [Platform.SWITCH]
+
+CONF_SERIAL_NUMBER: Final = "serial"
+
+MANUFACTURER: Final = "Qbus"
diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py
new file mode 100644
index 00000000000..dd57a98787b
--- /dev/null
+++ b/homeassistant/components/qbus/coordinator.py
@@ -0,0 +1,279 @@
+"""Qbus coordinator."""
+
+from __future__ import annotations
+
+from datetime import datetime
+import logging
+from typing import cast
+
+from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput
+from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
+
+from homeassistant.components.mqtt import (
+ ReceiveMessage,
+ async_wait_for_mqtt_client,
+ client as mqtt,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.util.hass_dict import HassKey
+
+from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator]
+QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN)
+
+
+class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
+ """Qbus data coordinator."""
+
+ _STATE_REQUEST_DELAY = 3
+
+ def __init__(self, hass: HomeAssistant, entry: QbusConfigEntry) -> None:
+ """Initialize Qbus coordinator."""
+
+ _LOGGER.debug("%s - Initializing coordinator", entry.unique_id)
+ self.config_entry: QbusConfigEntry
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=entry.unique_id or entry.entry_id,
+ always_update=False,
+ )
+
+ self._message_factory = QbusMqttMessageFactory()
+ self._topic_factory = QbusMqttTopicFactory()
+
+ self._controller_activated = False
+ self._subscribed_to_controller_state = False
+ self._controller: QbusMqttDevice | None = None
+
+ # Clean up when HA stops
+ self.config_entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
+ )
+
+ async def _async_update_data(self) -> list[QbusMqttOutput]:
+ return self._controller.outputs if self._controller else []
+
+ def shutdown(self, event: Event | None = None) -> None:
+ """Shutdown Qbus coordinator."""
+ _LOGGER.debug(
+ "%s - Shutting down entry coordinator", self.config_entry.unique_id
+ )
+
+ self._controller_activated = False
+ self._subscribed_to_controller_state = False
+ self._controller = None
+
+ async def async_update_controller_config(self, config: QbusDiscovery) -> None:
+ """Update the controller based on the config."""
+ _LOGGER.debug("%s - Updating config", self.config_entry.unique_id)
+ serial = self.config_entry.data.get(CONF_SERIAL_NUMBER, "")
+ controller = config.get_device_by_serial(serial)
+
+ if controller is None:
+ _LOGGER.warning(
+ "%s - Controller with serial %s not found",
+ self.config_entry.unique_id,
+ serial,
+ )
+ return
+
+ self._controller = controller
+
+ self._update_device_info()
+ await self._async_subscribe_to_controller_state()
+ await self.async_refresh()
+ self._request_controller_state()
+ self._request_entity_states()
+
+ def _update_device_info(self) -> None:
+ if self._controller is None:
+ return
+
+ device_registry = dr.async_get(self.hass)
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={(DOMAIN, format_mac(self._controller.mac))},
+ manufacturer=MANUFACTURER,
+ model="CTD3.x",
+ name=f"CTD {self._controller.serial_number}",
+ serial_number=self._controller.serial_number,
+ sw_version=self._controller.version,
+ )
+
+ async def _async_subscribe_to_controller_state(self) -> None:
+ if self._controller is None or self._subscribed_to_controller_state is True:
+ return
+
+ controller_state_topic = self._topic_factory.get_device_state_topic(
+ self._controller.id
+ )
+ _LOGGER.debug(
+ "%s - Subscribing to %s",
+ self.config_entry.unique_id,
+ controller_state_topic,
+ )
+ self._subscribed_to_controller_state = True
+ self.config_entry.async_on_unload(
+ await mqtt.async_subscribe(
+ self.hass,
+ controller_state_topic,
+ self._controller_state_received,
+ )
+ )
+
+ async def _controller_state_received(self, msg: ReceiveMessage) -> None:
+ _LOGGER.debug(
+ "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic
+ )
+
+ if self._controller is None or self._controller_activated:
+ return
+
+ state = self._message_factory.parse_device_state(msg.payload)
+
+ if state and state.properties and state.properties.connectable is False:
+ _LOGGER.debug(
+ "%s - Activating controller %s", self.config_entry.unique_id, state.id
+ )
+ self._controller_activated = True
+ request = self._message_factory.create_device_activate_request(
+ self._controller
+ )
+ await mqtt.async_publish(self.hass, request.topic, request.payload)
+
+ def _request_entity_states(self) -> None:
+ async def request_state(_: datetime) -> None:
+ if self._controller is None:
+ return
+
+ _LOGGER.debug(
+ "%s - Requesting %s entity states",
+ self.config_entry.unique_id,
+ len(self._controller.outputs),
+ )
+
+ request = self._message_factory.create_state_request(
+ [item.id for item in self._controller.outputs]
+ )
+
+ await mqtt.async_publish(self.hass, request.topic, request.payload)
+
+ if self._controller and len(self._controller.outputs) > 0:
+ async_call_later(self.hass, self._STATE_REQUEST_DELAY, request_state)
+
+ def _request_controller_state(self) -> None:
+ async def request_controller_state(_: datetime) -> None:
+ if self._controller is None:
+ return
+
+ _LOGGER.debug(
+ "%s - Requesting controller state", self.config_entry.unique_id
+ )
+ request = self._message_factory.create_device_state_request(
+ self._controller
+ )
+ await mqtt.async_publish(self.hass, request.topic, request.payload)
+
+ if self._controller:
+ async_call_later(
+ self.hass, self._STATE_REQUEST_DELAY, request_controller_state
+ )
+
+
+class QbusConfigCoordinator:
+ """Class responsible for Qbus config updates."""
+
+ _qbus_config: QbusDiscovery | None = None
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize config coordinator."""
+
+ self._hass = hass
+ self._message_factory = QbusMqttMessageFactory()
+ self._topic_factory = QbusMqttTopicFactory()
+ self._cleanup_callbacks: list[CALLBACK_TYPE] = []
+
+ self._cleanup_callbacks.append(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
+ )
+
+ @classmethod
+ def get_or_create(cls, hass: HomeAssistant) -> QbusConfigCoordinator:
+ """Get the coordinator and create if necessary."""
+ if (coordinator := hass.data.get(QBUS_KEY)) is None:
+ coordinator = cls(hass)
+ hass.data[QBUS_KEY] = coordinator
+
+ return coordinator
+
+ def shutdown(self, event: Event | None = None) -> None:
+ """Shutdown Qbus config coordinator."""
+ _LOGGER.debug("Shutting down Qbus config coordinator")
+ while self._cleanup_callbacks:
+ cleanup_callback = self._cleanup_callbacks.pop()
+ cleanup_callback()
+
+ async def async_subscribe_to_config(self) -> None:
+ """Subscribe to config changes."""
+ config_topic = self._topic_factory.get_config_topic()
+ _LOGGER.debug("Subscribing to %s", config_topic)
+
+ self._cleanup_callbacks.append(
+ await mqtt.async_subscribe(self._hass, config_topic, self._config_received)
+ )
+
+ async def async_get_or_request_config(self) -> QbusDiscovery | None:
+ """Get or request Qbus config."""
+ _LOGGER.debug("Requesting Qbus config")
+
+ # Config already available
+ if self._qbus_config:
+ _LOGGER.debug("Qbus config already available")
+ return self._qbus_config
+
+ if not await async_wait_for_mqtt_client(self._hass):
+ _LOGGER.debug("MQTT client not ready yet")
+ return None
+
+ # Request config
+ _LOGGER.debug("Publishing config request")
+ await mqtt.async_publish(
+ self._hass, self._topic_factory.get_get_config_topic(), b""
+ )
+
+ return self._qbus_config
+
+ def store_config(self, config: QbusDiscovery) -> None:
+ "Store the Qbus config."
+ _LOGGER.debug("Storing config")
+
+ self._qbus_config = config
+
+ async def _config_received(self, msg: ReceiveMessage) -> None:
+ """Handle the received MQTT message containing the Qbus config."""
+ _LOGGER.debug("Receiving Qbus config")
+
+ config = self._message_factory.parse_discovery(msg.payload)
+
+ if config is None:
+ _LOGGER.debug("Incomplete Qbus config")
+ return
+
+ self.store_config(config)
+
+ for entry in self._hass.config_entries.async_loaded_entries(DOMAIN):
+ entry = cast(QbusConfigEntry, entry)
+ await entry.runtime_data.async_update_controller_config(config)
diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py
new file mode 100644
index 00000000000..39bcddaaf4f
--- /dev/null
+++ b/homeassistant/components/qbus/entity.py
@@ -0,0 +1,76 @@
+"""Base class for Qbus entities."""
+
+from abc import ABC, abstractmethod
+import re
+
+from qbusmqttapi.discovery import QbusMqttOutput
+from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
+from qbusmqttapi.state import QbusMqttState
+
+from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
+from homeassistant.helpers.device_registry import DeviceInfo, format_mac
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN, MANUFACTURER
+
+_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
+
+
+def format_ref_id(ref_id: str) -> str | None:
+ """Format the Qbus ref_id."""
+ matches: list[str] = re.findall(_REFID_REGEX, ref_id)
+
+ if len(matches) > 0:
+ if ref_id := matches[0]:
+ return ref_id.replace("/", "-")
+
+ return None
+
+
+class QbusEntity(Entity, ABC):
+ """Representation of a Qbus entity."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_should_poll = False
+
+ def __init__(self, mqtt_output: QbusMqttOutput) -> None:
+ """Initialize the Qbus entity."""
+
+ self._topic_factory = QbusMqttTopicFactory()
+ self._message_factory = QbusMqttMessageFactory()
+
+ ref_id = format_ref_id(mqtt_output.ref_id)
+
+ self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
+
+ self._attr_device_info = DeviceInfo(
+ name=mqtt_output.name.title(),
+ manufacturer=MANUFACTURER,
+ identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
+ suggested_area=mqtt_output.location.title(),
+ via_device=(DOMAIN, format_mac(mqtt_output.device.mac)),
+ )
+
+ self._mqtt_output = mqtt_output
+ self._state_topic = self._topic_factory.get_output_state_topic(
+ mqtt_output.device.id, mqtt_output.id
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ self.async_on_remove(
+ await mqtt.async_subscribe(
+ self.hass, self._state_topic, self._state_received
+ )
+ )
+
+ @abstractmethod
+ async def _state_received(self, msg: ReceiveMessage) -> None:
+ pass
+
+ async def _async_publish_output_state(self, state: QbusMqttState) -> None:
+ request = self._message_factory.create_set_output_state_request(
+ self._mqtt_output.device, state
+ )
+ await mqtt.async_publish(self.hass, request.topic, request.payload)
diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json
new file mode 100644
index 00000000000..b7d277f3953
--- /dev/null
+++ b/homeassistant/components/qbus/manifest.json
@@ -0,0 +1,17 @@
+{
+ "domain": "qbus",
+ "name": "Qbus",
+ "codeowners": ["@Qbus-iot", "@thomasddn"],
+ "config_flow": true,
+ "dependencies": ["mqtt"],
+ "documentation": "https://www.home-assistant.io/integrations/qbus",
+ "integration_type": "hub",
+ "iot_class": "local_push",
+ "mqtt": [
+ "cloudapp/QBUSMQTTGW/state",
+ "cloudapp/QBUSMQTTGW/config",
+ "cloudapp/QBUSMQTTGW/+/state"
+ ],
+ "quality_scale": "bronze",
+ "requirements": ["qbusmqttapi==1.2.4"]
+}
diff --git a/homeassistant/components/qbus/quality_scale.yaml b/homeassistant/components/qbus/quality_scale.yaml
new file mode 100644
index 00000000000..7e106ef6b93
--- /dev/null
+++ b/homeassistant/components/qbus/quality_scale.yaml
@@ -0,0 +1,89 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ The integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: exempt
+ comment: |
+ The integration relies solely on auto-discovery.
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options flow.
+ docs-installation-parameters:
+ status: exempt
+ comment: There are no parameters.
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: The integration does not require authentication.
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations:
+ status: exempt
+ comment: The integration uses the name of what the user configured in the closed system.
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: The integration creates unknown number of entities based on what is in the closed system and does not know what each entity stands for.
+ reconfiguration-flow:
+ status: exempt
+ comment: The integration has no settings.
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: The integration does not make HTTP requests.
+ strict-typing: done
diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json
new file mode 100644
index 00000000000..b8918497c41
--- /dev/null
+++ b/homeassistant/components/qbus/strings.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "flow_title": "Controller {serial}",
+ "step": {
+ "discovery_confirm": {
+ "title": "Add controller",
+ "description": "Add controller {serial}?"
+ }
+ },
+ "abort": {
+ "already_configured": "Controller already configured",
+ "discovery_in_progress": "Discovery in progress",
+ "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention."
+ },
+ "error": {
+ "no_controller": "No controllers were found"
+ }
+ }
+}
diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py
new file mode 100644
index 00000000000..2413b8f152f
--- /dev/null
+++ b/homeassistant/components/qbus/switch.py
@@ -0,0 +1,83 @@
+"""Support for Qbus switch."""
+
+from typing import Any
+
+from qbusmqttapi.discovery import QbusMqttOutput
+from qbusmqttapi.state import QbusMqttOnOffState, StateType
+
+from homeassistant.components.mqtt import ReceiveMessage
+from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import QbusConfigEntry
+from .entity import QbusEntity
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: QbusConfigEntry, add_entities: AddEntitiesCallback
+) -> None:
+ """Set up switch entities."""
+ coordinator = entry.runtime_data
+
+ added_outputs: list[QbusMqttOutput] = []
+
+ # Local function that calls add_entities for new entities
+ def _check_outputs() -> None:
+ added_output_ids = {k.id for k in added_outputs}
+
+ new_outputs = [
+ item
+ for item in coordinator.data
+ if item.type == "onoff" and item.id not in added_output_ids
+ ]
+
+ if new_outputs:
+ added_outputs.extend(new_outputs)
+ add_entities([QbusSwitch(output) for output in new_outputs])
+
+ _check_outputs()
+ entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+
+
+class QbusSwitch(QbusEntity, SwitchEntity):
+ """Representation of a Qbus switch entity."""
+
+ _attr_device_class = SwitchDeviceClass.SWITCH
+
+ def __init__(
+ self,
+ mqtt_output: QbusMqttOutput,
+ ) -> None:
+ """Initialize switch entity."""
+
+ super().__init__(mqtt_output)
+
+ self._attr_is_on = False
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE)
+ state.write_value(True)
+
+ await self._async_publish_output_state(state)
+ self._attr_is_on = True
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE)
+ state.write_value(False)
+
+ await self._async_publish_output_state(state)
+ self._attr_is_on = False
+
+ async def _state_received(self, msg: ReceiveMessage) -> None:
+ output = self._message_factory.parse_output_state(
+ QbusMqttOnOffState, msg.payload
+ )
+
+ if output is not None:
+ self._attr_is_on = output.read_value()
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py
index c5efe03a878..990eb5116eb 100644
--- a/homeassistant/components/qingping/config_flow.py
+++ b/homeassistant/components/qingping/config_flow.py
@@ -98,7 +98,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py
index c1266ab951b..c235d441133 100644
--- a/homeassistant/components/qld_bushfire/geo_location.py
+++ b/homeassistant/components/qld_bushfire/geo_location.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfLength,
)
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import track_time_interval
diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py
index 3a10e54ac82..3ccb13e0f64 100644
--- a/homeassistant/components/qnap_qsw/config_flow.py
+++ b/homeassistant/components/qnap_qsw/config_flow.py
@@ -9,12 +9,12 @@ from aioqsw.exceptions import LoginError, QswError
from aioqsw.localapi import ConnectionOptions, QnapQswApi
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
@@ -73,7 +73,7 @@ class QNapQSWConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
self._discovered_url = f"http://{discovery_info.ip}"
diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py
index 45ec1828b9d..e7f2c18638f 100644
--- a/homeassistant/components/qnap_qsw/sensor.py
+++ b/homeassistant/components/qnap_qsw/sensor.py
@@ -27,12 +27,9 @@ from aioqsw.const import (
QSD_TEMP_MAX,
QSD_TX_OCTETS,
QSD_TX_SPEED,
- QSD_UPTIME_SECONDS,
QSD_UPTIME_TIMESTAMP,
)
-from homeassistant.components.automation import automations_with_entity
-from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -45,10 +42,8 @@ from homeassistant.const import (
UnitOfDataRate,
UnitOfInformation,
UnitOfTemperature,
- UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType
from homeassistant.util import dt as dt_util
@@ -68,16 +63,6 @@ class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription):
value_fn: Callable[[str], datetime | StateType] = lambda value: value
-DEPRECATED_UPTIME_SECONDS = QswSensorEntityDescription(
- translation_key="uptime",
- key=QSD_SYSTEM_TIME,
- entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- state_class=SensorStateClass.TOTAL_INCREASING,
- subkey=QSD_UPTIME_SECONDS,
-)
-
-
SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = (
QswSensorEntityDescription(
translation_key="fan_1_speed",
@@ -355,46 +340,6 @@ async def async_setup_entry(
)
entities.append(QswSensor(coordinator, _desc, entry, port_id))
- # Can be removed in HA 2025.5.0
- entity_reg = er.async_get(hass)
- reg_entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
- for entity in reg_entities:
- if entity.domain == "sensor" and entity.unique_id.endswith(
- ("_uptime", "_uptime_seconds")
- ):
- entity_id = entity.entity_id
-
- if entity.disabled:
- entity_reg.async_remove(entity_id)
- continue
-
- if (
- DEPRECATED_UPTIME_SECONDS.key in coordinator.data
- and DEPRECATED_UPTIME_SECONDS.subkey
- in coordinator.data[DEPRECATED_UPTIME_SECONDS.key]
- ):
- entities.append(
- QswSensor(coordinator, DEPRECATED_UPTIME_SECONDS, entry)
- )
-
- entity_automations = automations_with_entity(hass, entity_id)
- entity_scripts = scripts_with_entity(hass, entity_id)
-
- for item in entity_automations + entity_scripts:
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"uptime_seconds_deprecated_{entity_id}_{item}",
- breaks_in_ha_version="2025.5.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="uptime_seconds_deprecated",
- translation_placeholders={
- "entity": entity_id,
- "info": item,
- },
- )
-
async_add_entities(entities)
diff --git a/homeassistant/components/qnap_qsw/strings.json b/homeassistant/components/qnap_qsw/strings.json
index 462e66a25c3..e946bc4257d 100644
--- a/homeassistant/components/qnap_qsw/strings.json
+++ b/homeassistant/components/qnap_qsw/strings.json
@@ -57,11 +57,5 @@
"name": "Uptime timestamp"
}
}
- },
- "issues": {
- "uptime_seconds_deprecated": {
- "title": "QNAP QSW uptime seconds sensor deprecated",
- "description": "The QNAP QSW uptime seconds sensor entity is deprecated and will be removed in HA 2025.2.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the uptime seconds entity from `{info}` then click submit to fix this issue."
- }
}
}
diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json
index 9634d45b069..cd3ee8eca42 100644
--- a/homeassistant/components/qrcode/manifest.json
+++ b/homeassistant/components/qrcode/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "calculated",
"loggers": ["pyzbar"],
"quality_scale": "legacy",
- "requirements": ["Pillow==11.0.0", "pyzbar==0.1.7"]
+ "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"]
}
diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py
index dc68472d94e..6491dca2e2c 100644
--- a/homeassistant/components/quantum_gateway/device_tracker.py
+++ b/homeassistant/components/quantum_gateway/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py
index 9aad94790c6..98f0bcbaf99 100644
--- a/homeassistant/components/qvr_pro/__init__.py
+++ b/homeassistant/components/qvr_pro/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py
index 776e32dded1..d3cf2ff3d9b 100644
--- a/homeassistant/components/qwikswitch/__init__.py
+++ b/homeassistant/components/qwikswitch/__init__.py
@@ -18,8 +18,8 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py
index 64e560b4f08..64b95fb17f6 100644
--- a/homeassistant/components/qwikswitch/sensor.py
+++ b/homeassistant/components/qwikswitch/sensor.py
@@ -48,9 +48,9 @@ class QSSensor(QSEntity, SensorEntity):
self._decode, self.unit = SENSORS[sensor_type]
# this cannot happen because it only happens in bool and this should be redirected to binary_sensor
- assert not isinstance(
- self.unit, type
- ), f"boolean sensor id={sensor['id']} name={sensor['name']}"
+ assert not isinstance(self.unit, type), (
+ f"boolean sensor id={sensor['id']} name={sensor['name']}"
+ )
@callback
def update_packet(self, packet):
diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py
index 1bee69219b0..f4487a73b58 100644
--- a/homeassistant/components/rabbitair/config_flow.py
+++ b/homeassistant/components/rabbitair/config_flow.py
@@ -14,6 +14,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -99,7 +100,7 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
mac = dr.format_mac(discovery_info.properties["id"])
diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py
index fac93952b35..cc32bd2e56f 100644
--- a/homeassistant/components/rachio/config_flow.py
+++ b/homeassistant/components/rachio/config_flow.py
@@ -10,7 +10,6 @@ from rachiopy import Rachio
from requests.exceptions import ConnectTimeout
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -20,6 +19,10 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo,
+)
from .const import (
CONF_MANUAL_RUN_MINS,
@@ -92,13 +95,11 @@ class RachioConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle HomeKit discovery."""
self._async_abort_entries_match()
- await self.async_set_unique_id(
- discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID]
- )
+ await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID])
self._abort_if_unique_id_configured()
return await self.async_step_user()
diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py
index e29c4703e08..298421d3964 100644
--- a/homeassistant/components/radiotherm/config_flow.py
+++ b/homeassistant/components/radiotherm/config_flow.py
@@ -9,11 +9,11 @@ from urllib.error import URLError
from radiotherm.validate import RadiothermTstatError
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
from .data import RadioThermInitData, async_get_init_data
@@ -44,7 +44,7 @@ class RadioThermConfigFlow(ConfigFlow, domain=DOMAIN):
self.discovered_init_data: RadioThermInitData | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Discover via DHCP."""
self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
index 4827ac3e67c..d8b71e2df0b 100644
--- a/homeassistant/components/rainbird/__init__.py
+++ b/homeassistant/components/rainbird/__init__.py
@@ -79,14 +79,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) ->
return False
if mac_address := entry.data.get(CONF_MAC):
_async_fix_entity_unique_id(
- hass,
er.async_get(hass),
entry.entry_id,
format_mac(mac_address),
str(entry.data[CONF_SERIAL_NUMBER]),
)
_async_fix_device_id(
- hass,
dr.async_get(hass),
entry.entry_id,
format_mac(mac_address),
@@ -170,7 +168,6 @@ async def _async_fix_unique_id(
def _async_fix_entity_unique_id(
- hass: HomeAssistant,
entity_registry: er.EntityRegistry,
config_entry_id: str,
mac_address: str,
@@ -214,7 +211,6 @@ def _async_device_entry_to_keep(
def _async_fix_device_id(
- hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry_id: str,
mac_address: str,
diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py
index f1eef40f307..0ee12612323 100644
--- a/homeassistant/components/raincloud/__init__.py
+++ b/homeassistant/components/raincloud/__init__.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import track_time_interval
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py
index 2696c192ed6..84621aba99d 100644
--- a/homeassistant/components/raincloud/binary_sensor.py
+++ b/homeassistant/components/raincloud/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py
index 1f9d8d7b2c5..8aaec605c04 100644
--- a/homeassistant/components/raincloud/sensor.py
+++ b/homeassistant/components/raincloud/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py
index 59a11a6b167..babadcba676 100644
--- a/homeassistant/components/raincloud/switch.py
+++ b/homeassistant/components/raincloud/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py
index 72d258dc1d3..f8e3dde446a 100644
--- a/homeassistant/components/rainforest_raven/config_flow.py
+++ b/homeassistant/components/rainforest_raven/config_flow.py
@@ -20,6 +20,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
SelectSelectorMode,
)
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import DEFAULT_NAME, DOMAIN
@@ -30,7 +31,7 @@ def _format_id(value: str | int) -> str:
return f"{value or 0:04X}"
-def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str:
+def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str:
"""Generate unique id from usb attributes."""
return (
f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}"
@@ -98,9 +99,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(step_id="meters", data_schema=schema, errors=errors)
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB Discovery."""
device = discovery_info.device
dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device)
diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py
index 0b40d506566..6ce95d7e547 100644
--- a/homeassistant/components/rainmachine/config_flow.py
+++ b/homeassistant/components/rainmachine/config_flow.py
@@ -9,7 +9,6 @@ from regenmaschine.controller import Controller
from regenmaschine.errors import RainMachineError
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -19,6 +18,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_ALLOW_INACTIVE_ZONES_TO_RUN,
@@ -66,19 +66,19 @@ class RainMachineFlowHandler(ConfigFlow, domain=DOMAIN):
return RainMachineOptionsFlowHandler()
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by homekit discovery."""
return await self.async_step_homekit_zeroconf(discovery_info)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via zeroconf."""
return await self.async_step_homekit_zeroconf(discovery_info)
async def async_step_homekit_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via zeroconf."""
ip_address = discovery_info.host
diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py
index ae9a5886d59..fadc966bc3d 100644
--- a/homeassistant/components/random/binary_sensor.py
+++ b/homeassistant/components/random/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py
index 35b7757580e..406100388e6 100644
--- a/homeassistant/components/random/config_flow.py
+++ b/homeassistant/components/random/config_flow.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py
index aad4fcb851c..590b391c3a0 100644
--- a/homeassistant/components/random/sensor.py
+++ b/homeassistant/components/random/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py
index 37835ecb40a..b9506c3688c 100644
--- a/homeassistant/components/raspyrfm/switch.py
+++ b/homeassistant/components/raspyrfm/switch.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
DEVICE_DEFAULT_NAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py
index a40760c67f4..5a95ace92cb 100644
--- a/homeassistant/components/recorder/__init__.py
+++ b/homeassistant/components/recorder/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import (
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER,
diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py
index b73744ef0d1..f203d6ab69a 100644
--- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py
+++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py
@@ -17,7 +17,7 @@ from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.util import dt as dt_util
-from ...const import SQLITE_MAX_BIND_VARS
+from ...const import DEFAULT_MAX_BIND_VARS
from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm
from ...util import database_job_retry_wrapper, execute
@@ -61,7 +61,7 @@ def _find_duplicates(
)
.filter(subquery.c.is_duplicate == 1)
.order_by(table.metadata_id, table.start, table.id.desc())
- .limit(1000 * SQLITE_MAX_BIND_VARS)
+ .limit(1000 * DEFAULT_MAX_BIND_VARS)
)
duplicates = execute(query)
original_as_dict = {}
@@ -125,10 +125,10 @@ def _delete_duplicates_from_table(
if not duplicate_ids:
break
all_non_identical_duplicates.extend(non_identical_duplicates)
- for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS):
+ for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS):
deleted_rows = (
session.query(table)
- .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]))
+ .filter(table.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS]))
.delete(synchronize_session=False)
)
total_deleted_rows += deleted_rows
@@ -205,7 +205,7 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]:
)
.filter(subquery.c.is_duplicate == 1)
.order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc())
- .limit(1000 * SQLITE_MAX_BIND_VARS)
+ .limit(1000 * DEFAULT_MAX_BIND_VARS)
)
duplicates = execute(query)
statistic_id = None
@@ -230,11 +230,11 @@ def _delete_statistics_meta_duplicates(session: Session) -> int:
duplicate_ids = _find_statistics_meta_duplicates(session)
if not duplicate_ids:
break
- for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS):
+ for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS):
deleted_rows = (
session.query(StatisticsMeta)
.filter(
- StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])
+ StatisticsMeta.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS])
)
.delete(synchronize_session=False)
)
diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py
index 409641e54c9..c91845e8436 100644
--- a/homeassistant/components/recorder/const.py
+++ b/homeassistant/components/recorder/const.py
@@ -32,16 +32,6 @@ MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2
# The maximum number of rows (events) we purge in one delete statement
-# sqlite3 has a limit of 999 until version 3.32.0
-# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc
-# We can increase this back to 1000 once most
-# have upgraded their sqlite version
-SQLITE_MAX_BIND_VARS = 998
-
-# The maximum bind vars for sqlite 3.32.0 and above, but
-# capped at 4000 to avoid performance issues
-SQLITE_MODERN_MAX_BIND_VARS = 4000
-
DEFAULT_MAX_BIND_VARS = 4000
DB_WORKER_PREFIX = "DbWorker"
diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py
index fee72ce273f..05a5731e791 100644
--- a/homeassistant/components/recorder/core.py
+++ b/homeassistant/components/recorder/core.py
@@ -14,7 +14,7 @@ import threading
import time
from typing import TYPE_CHECKING, Any, cast
-from propcache import cached_property
+from propcache.api import cached_property
import psutil_home_assistant as ha_psutil
from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select, update
from sqlalchemy.engine import Engine
@@ -45,13 +45,14 @@ from homeassistant.helpers.event import (
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.event_type import EventType
from . import migration, statistics
from .const import (
DB_WORKER_PREFIX,
+ DEFAULT_MAX_BIND_VARS,
DOMAIN,
KEEPALIVE_TIME,
LAST_REPORTED_SCHEMA_VERSION,
@@ -61,7 +62,6 @@ from .const import (
MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG,
MYSQLDB_PYMYSQL_URL_PREFIX,
MYSQLDB_URL_PREFIX,
- SQLITE_MAX_BIND_VARS,
SQLITE_URL_PREFIX,
SupportedDialect,
)
@@ -230,12 +230,9 @@ class Recorder(threading.Thread):
self._dialect_name: SupportedDialect | None = None
self.enabled = True
- # For safety we default to the lowest value for max_bind_vars
- # of all the DB types (SQLITE_MAX_BIND_VARS).
- #
# We update the value once we connect to the DB
# and determine what is actually supported.
- self.max_bind_vars = SQLITE_MAX_BIND_VARS
+ self.max_bind_vars = DEFAULT_MAX_BIND_VARS
@property
def backlog(self) -> int:
diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py
index 2afbed9cb75..d1a2405406e 100644
--- a/homeassistant/components/recorder/db_schema.py
+++ b/homeassistant/components/recorder/db_schema.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from datetime import datetime, timedelta
import logging
import time
-from typing import Any, Final, Self, cast
+from typing import Any, Final, Protocol, Self, cast
import ciso8601
from fnv_hash_fast import fnv1a_32
@@ -47,7 +47,7 @@ from homeassistant.const import (
)
from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State
from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.json import (
JSON_DECODE_EXCEPTIONS,
json_loads,
@@ -233,10 +233,14 @@ CONTEXT_BINARY_TYPE = LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH).with_variant(
TIMESTAMP_TYPE = DOUBLE_TYPE
+class _LiteralProcessorType(Protocol):
+ def __call__(self, value: Any) -> str: ...
+
+
class JSONLiteral(JSON):
"""Teach SA how to literalize json."""
- def literal_processor(self, dialect: Dialect) -> Callable[[Any], str]:
+ def literal_processor(self, dialect: Dialect) -> _LiteralProcessorType:
"""Processor to convert a value to JSON."""
def process(value: Any) -> str:
diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py
index dc49ebb9768..4323ad9466b 100644
--- a/homeassistant/components/recorder/history/legacy.py
+++ b/homeassistant/components/recorder/history/legacy.py
@@ -20,7 +20,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement
from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.recorder import get_instance
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from ..db_schema import StateAttributes, States
from ..filters import Filters
diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py
index 2d8f4da5f38..aed2fcf8508 100644
--- a/homeassistant/components/recorder/history/modern.py
+++ b/homeassistant/components/recorder/history/modern.py
@@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session
from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.recorder import get_instance
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from ..const import LAST_REPORTED_SCHEMA_VERSION
from ..db_schema import (
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index 93ffb12d18c..7cef284ef60 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -7,8 +7,8 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
- "SQLAlchemy==2.0.36",
- "fnv-hash-fast==1.0.2",
+ "SQLAlchemy==2.0.37",
+ "fnv-hash-fast==1.2.2",
"psutil-home-assistant==0.0.1"
]
}
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
index 8c9252ba28b..c6cdd6d317f 100644
--- a/homeassistant/components/recorder/migration.py
+++ b/homeassistant/components/recorder/migration.py
@@ -2073,10 +2073,7 @@ def _wipe_old_string_time_columns(
session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;"))
session.commit()
session.execute(
- text(
- "UPDATE states set last_updated=NULL, last_changed=NULL "
- " LIMIT 100000;"
- )
+ text("UPDATE states set last_updated=NULL, last_changed=NULL LIMIT 100000;")
)
session.commit()
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
@@ -2150,7 +2147,7 @@ def _migrate_columns_to_timestamp(
)
)
result = None
- while result is None or result.rowcount > 0: # type: ignore[unreachable]
+ while result is None or result.rowcount > 0:
with session_scope(session=session_maker()) as session:
result = session.connection().execute(
text(
@@ -2181,7 +2178,7 @@ def _migrate_columns_to_timestamp(
)
)
result = None
- while result is None or result.rowcount > 0: # type: ignore[unreachable]
+ while result is None or result.rowcount > 0:
with session_scope(session=session_maker()) as session:
result = session.connection().execute(
text(
@@ -2280,7 +2277,7 @@ def _migrate_statistics_columns_to_timestamp(
# updated all rows in the table until the rowcount is 0
for table in STATISTICS_TABLES:
result = None
- while result is None or result.rowcount > 0: # type: ignore[unreachable]
+ while result is None or result.rowcount > 0:
with session_scope(session=session_maker()) as session:
result = session.connection().execute(
text(
@@ -2302,7 +2299,7 @@ def _migrate_statistics_columns_to_timestamp(
# updated all rows in the table until the rowcount is 0
for table in STATISTICS_TABLES:
result = None
- while result is None or result.rowcount > 0: # type: ignore[unreachable]
+ while result is None or result.rowcount > 0:
with session_scope(session=session_maker()) as session:
result = session.connection().execute(
text(
@@ -2453,7 +2450,7 @@ class BaseMigration(ABC):
self.migration_changes = migration_changes
@abstractmethod
- def migrate_data(self, instance: Recorder) -> bool:
+ def migrate_data(self, instance: Recorder, /) -> bool:
"""Migrate some data, return True if migration is completed."""
def _migrate_data(self, instance: Recorder) -> bool:
@@ -2755,9 +2752,9 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
for db_event_type in missing_db_event_types:
# We cannot add the assigned ids to the event_type_manager
# because the commit could get rolled back
- assert (
- db_event_type.event_type is not None
- ), "event_type should never be None"
+ assert db_event_type.event_type is not None, (
+ "event_type should never be None"
+ )
event_type_to_id[db_event_type.event_type] = (
db_event_type.event_type_id
)
@@ -2833,9 +2830,9 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
for db_states_metadata in missing_states_metadata:
# We cannot add the assigned ids to the event_type_manager
# because the commit could get rolled back
- assert (
- db_states_metadata.entity_id is not None
- ), "entity_id should never be None"
+ assert db_states_metadata.entity_id is not None, (
+ "entity_id should never be None"
+ )
entity_id_to_metadata_id[db_states_metadata.entity_id] = (
db_states_metadata.metadata_id
)
diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py
index a469aa49ab2..11ea9141fc0 100644
--- a/homeassistant/components/recorder/models/legacy.py
+++ b/homeassistant/components/recorder/models/legacy.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
COMPRESSED_STATE_STATE,
)
from homeassistant.core import Context, State
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .state_attributes import decode_attributes_from_source
from .time import process_timestamp
@@ -24,12 +24,12 @@ class LegacyLazyState(State):
"""A lazy version of core State after schema 31."""
__slots__ = [
- "_row",
"_attributes",
- "_last_changed_ts",
- "_last_updated_ts",
- "_last_reported_ts",
"_context",
+ "_last_changed_ts",
+ "_last_reported_ts",
+ "_last_updated_ts",
+ "_row",
"attr_cache",
]
diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py
index fbf73e75025..919ee078a99 100644
--- a/homeassistant/components/recorder/models/state.py
+++ b/homeassistant/components/recorder/models/state.py
@@ -6,7 +6,7 @@ from datetime import datetime
import logging
from typing import TYPE_CHECKING, Any
-from propcache import cached_property
+from propcache.api import cached_property
from sqlalchemy.engine.row import Row
from homeassistant.const import (
@@ -16,7 +16,7 @@ from homeassistant.const import (
COMPRESSED_STATE_STATE,
)
from homeassistant.core import Context, State
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .state_attributes import decode_attributes_from_source
@@ -58,8 +58,8 @@ class LazyState(State):
self.attr_cache = attr_cache
self.context = EMPTY_CONTEXT
- @cached_property # type: ignore[override]
- def attributes(self) -> dict[str, Any]:
+ @cached_property
+ def attributes(self) -> dict[str, Any]: # type: ignore[override]
"""State attributes."""
return decode_attributes_from_source(
getattr(self._row, "attributes", None), self.attr_cache
diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py
index 33218000faa..91acad1500e 100644
--- a/homeassistant/components/recorder/models/time.py
+++ b/homeassistant/components/recorder/models/time.py
@@ -6,7 +6,7 @@ from datetime import datetime
import logging
from typing import overload
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py
index fc2a8ccb1cc..30e277d7c0a 100644
--- a/homeassistant/components/recorder/pool.py
+++ b/homeassistant/components/recorder/pool.py
@@ -47,9 +47,9 @@ class RecorderPool(SingletonThreadPool, NullPool):
) -> None:
"""Create the pool."""
kw["pool_size"] = POOL_SIZE
- assert (
- recorder_and_worker_thread_ids is not None
- ), "recorder_and_worker_thread_ids is required"
+ assert recorder_and_worker_thread_ids is not None, (
+ "recorder_and_worker_thread_ids is required"
+ )
self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids
SingletonThreadPool.__init__(self, creator, **kw)
diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py
index 2be02fe8091..cc74d7a2376 100644
--- a/homeassistant/components/recorder/services.py
+++ b/homeassistant/components/recorder/services.py
@@ -9,13 +9,13 @@ import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import generate_filter
from homeassistant.helpers.service import (
async_extract_entity_ids,
async_register_admin_service,
)
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN
from .core import Recorder
diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py
index c6783a5cbc2..8995f57ef30 100644
--- a/homeassistant/components/recorder/statistics.py
+++ b/homeassistant/components/recorder/statistics.py
@@ -968,12 +968,10 @@ def _reduce_statistics(
return result
-def reduce_day_ts_factory() -> (
- tuple[
- Callable[[float, float], bool],
- Callable[[float], tuple[float, float]],
- ]
-):
+def reduce_day_ts_factory() -> tuple[
+ Callable[[float, float], bool],
+ Callable[[float], tuple[float, float]],
+]:
"""Return functions to match same day and day start end."""
_lower_bound: float = 0
_upper_bound: float = 0
@@ -1017,12 +1015,10 @@ def _reduce_statistics_per_day(
)
-def reduce_week_ts_factory() -> (
- tuple[
- Callable[[float, float], bool],
- Callable[[float], tuple[float, float]],
- ]
-):
+def reduce_week_ts_factory() -> tuple[
+ Callable[[float, float], bool],
+ Callable[[float], tuple[float, float]],
+]:
"""Return functions to match same week and week start end."""
_lower_bound: float = 0
_upper_bound: float = 0
@@ -1075,12 +1071,10 @@ def _find_month_end_time(timestamp: datetime) -> datetime:
)
-def reduce_month_ts_factory() -> (
- tuple[
- Callable[[float, float], bool],
- Callable[[float], tuple[float, float]],
- ]
-):
+def reduce_month_ts_factory() -> tuple[
+ Callable[[float, float], bool],
+ Callable[[float], tuple[float, float]],
+]:
"""Return functions to match same month and month start end."""
_lower_bound: float = 0
_upper_bound: float = 0
diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json
index 2ded6be58d6..43c2ecdc14f 100644
--- a/homeassistant/components/recorder/strings.json
+++ b/homeassistant/components/recorder/strings.json
@@ -16,10 +16,6 @@
"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/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py
index 16feaa19886..6923b792b8b 100644
--- a/homeassistant/components/recorder/system_health/__init__.py
+++ b/homeassistant/components/recorder/system_health/__init__.py
@@ -40,7 +40,7 @@ def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]:
and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name))
and (db_bytes := get_size(session, database_name))
):
- db_stats["estimated_db_size"] = f"{db_bytes/1024/1024:.2f} MiB"
+ db_stats["estimated_db_size"] = f"{db_bytes / 1024 / 1024:.2f} MiB"
return db_stats
diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py
index 4ca0aa18b88..191fa44c194 100644
--- a/homeassistant/components/recorder/table_managers/recorder_runs.py
+++ b/homeassistant/components/recorder/table_managers/recorder_runs.py
@@ -6,7 +6,7 @@ from datetime import datetime
from sqlalchemy.orm.session import Session
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from ..db_schema import RecorderRuns
diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py
index 4cf24eb79c5..a686c7c6498 100644
--- a/homeassistant/components/recorder/util.py
+++ b/homeassistant/components/recorder/util.py
@@ -34,16 +34,9 @@ from homeassistant.helpers.recorder import ( # noqa: F401
get_instance,
session_scope,
)
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
-from .const import (
- DEFAULT_MAX_BIND_VARS,
- DOMAIN,
- SQLITE_MAX_BIND_VARS,
- SQLITE_MODERN_MAX_BIND_VARS,
- SQLITE_URL_PREFIX,
- SupportedDialect,
-)
+from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect
from .db_schema import (
TABLE_RECORDER_RUNS,
TABLE_SCHEMA_CHANGES,
@@ -95,9 +88,7 @@ RECOMMENDED_MIN_VERSION_MARIA_DB_108 = _simple_version("10.8.4")
MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4")
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")
+MIN_VERSION_SQLITE = _simple_version("3.40.1")
# This is the maximum time after the recorder ends the session
@@ -376,37 +367,6 @@ def _raise_if_version_unsupported(
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:
@@ -505,7 +465,6 @@ def setup_connection_for_dialect(
version: AwesomeVersion | None = None
slow_range_in_select = False
if dialect_name == SupportedDialect.SQLITE:
- max_bind_vars = SQLITE_MAX_BIND_VARS
if first_connection:
old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined]
dbapi_connection.isolation_level = None # type: ignore[attr-defined]
@@ -523,23 +482,6 @@ def setup_connection_for_dialect(
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
-
# The upper bound on the cache size is approximately 16MiB of memory
execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384")
@@ -558,7 +500,6 @@ def setup_connection_for_dialect(
execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON")
elif dialect_name == SupportedDialect.MYSQL:
- max_bind_vars = DEFAULT_MAX_BIND_VARS
execute_on_connection(dbapi_connection, "SET session wait_timeout=28800")
if first_connection:
result = query_on_connection(dbapi_connection, "SELECT VERSION()")
@@ -599,7 +540,6 @@ def setup_connection_for_dialect(
# Ensure all times are using UTC to avoid issues with daylight savings
execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'")
elif dialect_name == SupportedDialect.POSTGRESQL:
- max_bind_vars = DEFAULT_MAX_BIND_VARS
# PostgreSQL does not support a skip/loose index scan so its
# also slow for large distinct queries:
# https://wiki.postgresql.org/wiki/Loose_indexscan
@@ -626,7 +566,7 @@ def setup_connection_for_dialect(
dialect=SupportedDialect(dialect_name),
version=version,
optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select),
- max_bind_vars=max_bind_vars,
+ max_bind_vars=DEFAULT_MAX_BIND_VARS,
)
@@ -987,10 +927,7 @@ def filter_unique_constraint_integrity_error(
if ignore:
_LOGGER.warning(
- (
- "Blocked attempt to insert duplicated %s rows, please report"
- " at %s"
- ),
+ "Blocked attempt to insert duplicated %s rows, please report at %s",
row_type,
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22",
exc_info=err,
diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py
index 78fc0a805f6..f5b566ce59d 100644
--- a/homeassistant/components/recswitch/switch.py
+++ b/homeassistant/components/recswitch/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py
index 35962ac091b..564cc6c3c06 100644
--- a/homeassistant/components/reddit/sensor.py
+++ b/homeassistant/components/reddit/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py
index 82cc2540f6c..11e92620fbb 100644
--- a/homeassistant/components/refoss/bridge.py
+++ b/homeassistant/components/refoss/bridge.py
@@ -9,7 +9,7 @@ from refoss_ha.discovery import Discovery, Listener
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
+from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
from .coordinator import RefossDataUpdateCoordinator
@@ -36,11 +36,21 @@ class DiscoveryService(Listener):
self.hass.data[DOMAIN][COORDINATORS].append(coordo)
await coordo.async_refresh()
+ _LOGGER.debug(
+ "Discover new device: %s, ip: %s",
+ device_info.dev_name,
+ device_info.inner_ip,
+ )
async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo)
async def device_update(self, device_info: DeviceInfo) -> None:
"""Handle updates in device information, update if ip has changed."""
for coordinator in self.hass.data[DOMAIN][COORDINATORS]:
if coordinator.device.device_info.mac == device_info.mac:
+ _LOGGER.debug(
+ "Update device %s ip to %s",
+ device_info.dev_name,
+ device_info.inner_ip,
+ )
coordinator.device.device_info.inner_ip = device_info.inner_ip
await coordinator.async_refresh()
diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py
index fe33cefc1bd..5b667940731 100644
--- a/homeassistant/components/refoss/config_flow.py
+++ b/homeassistant/components/refoss/config_flow.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_flow
-from .const import DISCOVERY_TIMEOUT, DOMAIN
+from .const import _LOGGER, DISCOVERY_TIMEOUT, DOMAIN
from .util import refoss_discovery_server
@@ -14,6 +14,9 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
refoss_discovery = await refoss_discovery_server(hass)
devices = await refoss_discovery.broadcast_msg(wait_for=DISCOVERY_TIMEOUT)
+ _LOGGER.debug(
+ "Discovered devices: [%s]", ", ".join([info.dev_name for info in devices])
+ )
return len(devices) > 0
diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py
index 851f8ba8f77..62db733ece5 100644
--- a/homeassistant/components/refoss/const.py
+++ b/homeassistant/components/refoss/const.py
@@ -11,7 +11,7 @@ COORDINATORS = "coordinators"
DATA_DISCOVERY_SERVICE = "refoss_discovery"
DISCOVERY_SCAN_INTERVAL = 30
-DISCOVERY_TIMEOUT = 8
+DISCOVERY_TIMEOUT = 20
DISPATCH_DEVICE_DISCOVERED = "refoss_device_discovered"
DISPATCHERS = "dispatchers"
diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py
index 8b03313d6d6..929d1b3962b 100644
--- a/homeassistant/components/refoss/coordinator.py
+++ b/homeassistant/components/refoss/coordinator.py
@@ -34,6 +34,11 @@ class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]):
self.last_update_success = True
self._error_count = 0
except DeviceTimeoutError:
+ _LOGGER.debug(
+ "Update device %s status timeout,ip: %s",
+ self.device.dev_name,
+ self.device.inner_ip,
+ )
self._error_count += 1
if self._error_count >= MAX_ERRORS:
diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py
index 502101608ec..662b7c89376 100644
--- a/homeassistant/components/refoss/entity.py
+++ b/homeassistant/components/refoss/entity.py
@@ -23,5 +23,7 @@ class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]):
connections={(CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, mac)},
manufacturer="Refoss",
+ sw_version=coordinator.device.fmware_version,
+ hw_version=coordinator.device.hdware_version,
name=coordinator.device.dev_name,
)
diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json
index da7050433f3..93ffe5b3f26 100644
--- a/homeassistant/components/refoss/manifest.json
+++ b/homeassistant/components/refoss/manifest.json
@@ -5,5 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/refoss",
"iot_class": "local_polling",
- "requirements": ["refoss-ha==1.2.5"]
+ "requirements": ["refoss-ha==1.2.5"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py
index 26454cae48d..7065470657f 100644
--- a/homeassistant/components/refoss/sensor.py
+++ b/homeassistant/components/refoss/sensor.py
@@ -27,6 +27,7 @@ from homeassistant.helpers.typing import StateType
from .bridge import RefossDataUpdateCoordinator
from .const import (
+ _LOGGER,
CHANNEL_DISPLAY_NAME,
COORDINATORS,
DISPATCH_DEVICE_DISCOVERED,
@@ -143,6 +144,7 @@ async def async_setup_entry(
for channel in device.channels
for description in descriptions
)
+ _LOGGER.debug("Device %s add sensor entity success", device.dev_name)
for coordinator in hass.data[DOMAIN][COORDINATORS]:
init_device(coordinator)
diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py
index 0f5aba0cfc4..aed132ecc3a 100644
--- a/homeassistant/components/refoss/switch.py
+++ b/homeassistant/components/refoss/switch.py
@@ -13,7 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .bridge import RefossDataUpdateCoordinator
-from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
+from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
from .entity import RefossEntity
@@ -37,6 +37,7 @@ async def async_setup_entry(
new_entities.append(entity)
async_add_entities(new_entities)
+ _LOGGER.debug("Device %s add switch entity success", device.dev_name)
for coordinator in hass.data[DOMAIN][COORDINATORS]:
init_device(coordinator)
diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py
index 40b27014211..1d9b281e9b7 100644
--- a/homeassistant/components/rejseplanen/sensor.py
+++ b/homeassistant/components/rejseplanen/sensor.py
@@ -20,10 +20,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py
index d544c42efe1..0d1c54efb56 100644
--- a/homeassistant/components/remember_the_milk/__init__.py
+++ b/homeassistant/components/remember_the_milk/__init__.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json
index da499b0584c..de6ae8a7f04 100644
--- a/homeassistant/components/remember_the_milk/strings.json
+++ b/homeassistant/components/remember_the_milk/strings.json
@@ -2,7 +2,7 @@
"services": {
"create_task": {
"name": "Create task",
- "description": "Creates (or update) a new task in your Remember The Milk account. If you want to update a task later on, you have to set an \"id\" when creating the task. Note: Updating a tasks does not support the smart syntax.",
+ "description": "Creates a new task in your Remember The Milk account or updates an existing one. If you want to update a task later on, you have to set an \"ID\" when creating the task. Note: Updating a task does not support the smart syntax.",
"fields": {
"name": {
"name": "[%key:common::config_flow::data::name%]",
diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py
index 36e482f0a29..f7d87fbf021 100644
--- a/homeassistant/components/remote/__init__.py
+++ b/homeassistant/components/remote/__init__.py
@@ -9,7 +9,7 @@ import functools as ft
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py
index b3a8075c6ba..42e8517c1e8 100644
--- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py
+++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py
index bf31e4bb55a..91b389c5a1e 100644
--- a/homeassistant/components/remote_rpi_gpio/switch.py
+++ b/homeassistant/components/remote_rpi_gpio/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json
index a4817fc84e6..1a599afe4e4 100644
--- a/homeassistant/components/renault/manifest.json
+++ b/homeassistant/components/renault/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
- "requirements": ["renault-api==0.2.8"]
+ "requirements": ["renault-api==0.2.9"]
}
diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py
index 56b3655ef94..00edd4547cb 100644
--- a/homeassistant/components/renson/fan.py
+++ b/homeassistant/components/renson/fan.py
@@ -18,8 +18,7 @@ import voluptuous as vol
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.util.percentage import (
diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json
index b756d16ea79..c81086502ad 100644
--- a/homeassistant/components/renson/strings.json
+++ b/homeassistant/components/renson/strings.json
@@ -186,46 +186,46 @@
"services": {
"set_timer_level": {
"name": "Set timer",
- "description": "Set the ventilation timer",
+ "description": "Sets the ventilation timer",
"fields": {
"timer_level": {
"name": "Level",
- "description": "Level setting"
+ "description": "Ventilation level"
},
"minutes": {
"name": "Time",
- "description": "Time of the timer (0 will disable the timer)"
+ "description": "Duration of the timer (0 will disable the timer)"
}
}
},
"set_breeze": {
- "name": "Set breeze",
- "description": "Set the breeze function of the ventilation system",
+ "name": "Set Breeze",
+ "description": "Sets the Breeze function of the ventilation system",
"fields": {
"breeze_level": {
"name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]",
- "description": "Ventilation level when breeze function is activated"
+ "description": "Ventilation level when Breeze function is activated"
},
"temperature": {
"name": "Temperature",
- "description": "Temperature when the breeze function should be activated"
+ "description": "Temperature when the Breeze function should be activated"
},
"activate": {
"name": "Activate",
- "description": "Activate or disable the breeze feature"
+ "description": "Activate or disable the Breeze feature"
}
}
},
"set_pollution_settings": {
"name": "Set pollution settings",
- "description": "Set all the pollution settings of the ventilation system",
+ "description": "Sets all the pollution settings of the ventilation system",
"fields": {
"day_pollution_level": {
- "name": "Day pollution Level",
+ "name": "Day pollution level",
"description": "Ventilation level when pollution is detected in the day"
},
"night_pollution_level": {
- "name": "Night pollution Level",
+ "name": "Night pollution level",
"description": "Ventilation level when pollution is detected in the night"
},
"humidity_control": {
@@ -242,11 +242,11 @@
},
"co2_threshold": {
"name": "CO2 threshold",
- "description": "Sets the CO2 pollution threshold level in ppm"
+ "description": "The CO2 pollution threshold level in ppm"
},
"co2_hysteresis": {
"name": "CO2 hysteresis",
- "description": "Sets the CO2 pollution threshold hysteresis level in ppm"
+ "description": "The CO2 pollution threshold hysteresis level in ppm"
}
}
}
diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py
index dd791bbaf1a..71ca5428740 100644
--- a/homeassistant/components/reolink/__init__.py
+++ b/homeassistant/components/reolink/__init__.py
@@ -5,9 +5,14 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
+from typing import Any
from reolink_aio.api import RETRY_ATTEMPTS
-from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
+from reolink_aio.exceptions import (
+ CredentialsInvalidError,
+ LoginPrivacyModeError,
+ ReolinkError,
+)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
@@ -19,14 +24,15 @@ from homeassistant.helpers import (
entity_registry as er,
)
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_USE_HTTPS, DOMAIN
+from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
from .host import ReolinkHost
from .services import async_setup_services
-from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch
+from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store
from .views import PlaybackProxyView
_LOGGER = logging.getLogger(__name__)
@@ -61,7 +67,9 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> bool:
"""Set up Reolink from a config entry."""
- host = ReolinkHost(hass, config_entry.data, config_entry.options)
+ host = ReolinkHost(
+ hass, config_entry.data, config_entry.options, config_entry.entry_id
+ )
try:
await host.async_init()
@@ -86,21 +94,25 @@ async def async_setup_entry(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
)
- # update the port info if needed for the next time
+ # update the config info if needed for the next time
if (
host.api.port != config_entry.data[CONF_PORT]
or host.api.use_https != config_entry.data[CONF_USE_HTTPS]
+ or host.api.supported(None, "privacy_mode")
+ != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
):
- _LOGGER.warning(
- "HTTP(s) port of Reolink %s, changed from %s to %s",
- host.api.nvr_name,
- config_entry.data[CONF_PORT],
- host.api.port,
- )
+ if host.api.port != config_entry.data[CONF_PORT]:
+ _LOGGER.warning(
+ "HTTP(s) port of Reolink %s, changed from %s to %s",
+ host.api.nvr_name,
+ config_entry.data[CONF_PORT],
+ host.api.port,
+ )
data = {
**config_entry.data,
CONF_PORT: host.api.port,
CONF_USE_HTTPS: host.api.use_https,
+ CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
}
hass.config_entries.async_update_entry(config_entry, data=data)
@@ -115,6 +127,8 @@ async def async_setup_entry(
await host.stop()
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(str(err)) from err
+ except LoginPrivacyModeError:
+ pass # HTTP API is shutdown when privacy mode is active
except ReolinkError as err:
host.credential_errors = 0
raise UpdateFailed(str(err)) from err
@@ -192,6 +206,23 @@ async def async_setup_entry(
hass.http.register_view(PlaybackProxyView(hass))
+ async def refresh(*args: Any) -> None:
+ """Request refresh of coordinator."""
+ await device_coordinator.async_request_refresh()
+ host.cancel_refresh_privacy_mode = None
+
+ def async_privacy_mode_change() -> None:
+ """Request update when privacy mode is turned off."""
+ if host.privacy_mode and not host.api.baichuan.privacy_mode():
+ # The privacy mode just turned off, give the API 2 seconds to start
+ if host.cancel_refresh_privacy_mode is None:
+ host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh)
+ host.privacy_mode = host.api.baichuan.privacy_mode()
+
+ host.api.baichuan.register_callback(
+ "privacy_mode_change", async_privacy_mode_change, 623
+ )
+
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(
@@ -216,9 +247,21 @@ async def async_unload_entry(
await host.stop()
+ host.api.baichuan.unregister_callback("privacy_mode_change")
+ if host.cancel_refresh_privacy_mode is not None:
+ host.cancel_refresh_privacy_mode()
+
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
+async def async_remove_entry(
+ hass: HomeAssistant, config_entry: ReolinkConfigEntry
+) -> None:
+ """Handle removal of an entry."""
+ store = get_store(hass, config_entry.entry_id)
+ await store.async_remove()
+
+
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry
) -> bool:
@@ -361,7 +404,7 @@ def migrate_entity_ids(
if host.api.supported(None, "UID") and not entity.unique_id.startswith(
host.unique_id
):
- new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}"
+ new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}"
entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)
if entity.device_id in ch_device_ids:
diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py
index c168c97e809..2191dedc9cf 100644
--- a/homeassistant/components/reolink/binary_sensor.py
+++ b/homeassistant/components/reolink/binary_sensor.py
@@ -100,6 +100,13 @@ BINARY_PUSH_SENSORS = (
value=lambda api, ch: api.visitor_detected(ch),
supported=lambda api, ch: api.is_doorbell(ch),
),
+ ReolinkBinarySensorEntityDescription(
+ key="cry",
+ cmd_id=33,
+ translation_key="cry",
+ value=lambda api, ch: api.ai_detected(ch, "cry"),
+ supported=lambda api, ch: api.ai_supported(ch, "cry"),
+ ),
)
BINARY_SENSORS = (
diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py
index c28e076aab4..7943cadef21 100644
--- a/homeassistant/components/reolink/config_flow.py
+++ b/homeassistant/components/reolink/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import Mapping
import logging
from typing import Any
@@ -11,11 +12,11 @@ from reolink_aio.exceptions import (
ApiError,
CredentialsInvalidError,
LoginFirmwareError,
+ LoginPrivacyModeError,
ReolinkError,
)
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
@@ -34,8 +35,9 @@ from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import CONF_USE_HTTPS, DOMAIN
+from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import (
PasswordIncompatible,
ReolinkException,
@@ -49,6 +51,7 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_PROTOCOL = "rtsp"
DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL}
+API_STARTUP_TIME = 5
class ReolinkOptionsFlowHandler(OptionsFlow):
@@ -101,6 +104,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
self._host: str | None = None
self._username: str = "admin"
self._password: str | None = None
+ self._user_input: dict[str, Any] | None = None
+ self._disable_privacy: bool = False
@staticmethod
@callback
@@ -142,7 +147,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
mac_address = format_mac(discovery_info.macaddress)
@@ -198,6 +203,21 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
self._host = discovery_info.ip
return await self.async_step_user()
+ async def async_step_privacy(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Ask permission to disable privacy mode."""
+ if user_input is not None:
+ self._disable_privacy = True
+ return await self.async_step_user(self._user_input)
+
+ assert self._user_input is not None
+ placeholders = {"host": self._user_input[CONF_HOST]}
+ return self.async_show_form(
+ step_id="privacy",
+ description_placeholders=placeholders,
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -219,6 +239,10 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS)
try:
+ if self._disable_privacy:
+ await host.api.baichuan.set_privacy_mode(enable=False)
+ # give the camera some time to startup the HTTP API server
+ await asyncio.sleep(API_STARTUP_TIME)
await host.async_init()
except UserNotAdmin:
errors[CONF_USERNAME] = "not_admin"
@@ -227,6 +251,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
except PasswordIncompatible:
errors[CONF_PASSWORD] = "password_incompatible"
placeholders["special_chars"] = ALLOWED_SPECIAL_CHARS
+ except LoginPrivacyModeError:
+ self._user_input = user_input
+ return await self.async_step_privacy()
except CredentialsInvalidError:
errors[CONF_PASSWORD] = "invalid_auth"
except LoginFirmwareError:
@@ -260,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
user_input[CONF_PORT] = host.api.port
user_input[CONF_USE_HTTPS] = host.api.use_https
+ user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
+ None, "privacy_mode"
+ )
mac_address = format_mac(host.api.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False)
diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py
index 8aa01bfac41..7bd93337c46 100644
--- a/homeassistant/components/reolink/const.py
+++ b/homeassistant/components/reolink/const.py
@@ -3,3 +3,4 @@
DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
+CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py
index dc2366e8f56..63c95c25025 100644
--- a/homeassistant/components/reolink/entity.py
+++ b/homeassistant/components/reolink/entity.py
@@ -69,7 +69,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
super().__init__(coordinator)
self._host = reolink_data.host
- self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}"
+ self._attr_unique_id: str = (
+ f"{self._host.unique_id}_{self.entity_description.key}"
+ )
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}"
@@ -90,7 +92,11 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
@property
def available(self) -> bool:
"""Return True if entity is available."""
- return self._host.api.session_active and super().available
+ return (
+ self._host.api.session_active
+ and not self._host.api.baichuan.privacy_mode()
+ and super().available
+ )
@callback
def _push_callback(self) -> None:
@@ -110,8 +116,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
cmd_id = self.entity_description.cmd_id
if cmd_key is not None:
self._host.async_register_update_cmd(cmd_key)
- if cmd_id is not None and self._attr_unique_id is not None:
+ if cmd_id is not None:
self.register_callback(self._attr_unique_id, cmd_id)
+ # Privacy mode
+ self.register_callback(f"{self._attr_unique_id}_623", 623)
async def async_will_remove_from_hass(self) -> None:
"""Entity removed."""
@@ -119,8 +127,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
cmd_id = self.entity_description.cmd_id
if cmd_key is not None:
self._host.async_unregister_update_cmd(cmd_key)
- if cmd_id is not None and self._attr_unique_id is not None:
+ if cmd_id is not None:
self._host.api.baichuan.unregister_callback(self._attr_unique_id)
+ # Privacy mode
+ self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623")
await super().async_will_remove_from_hass()
diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py
index 97d888c0323..a23f53ff9cd 100644
--- a/homeassistant/components/reolink/host.py
+++ b/homeassistant/components/reolink/host.py
@@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url
+from homeassistant.helpers.storage import Store
from homeassistant.util.ssl import SSLCipherList
-from .const import CONF_USE_HTTPS, DOMAIN
+from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import (
PasswordIncompatible,
ReolinkSetupException,
ReolinkWebhookException,
UserNotAdmin,
)
+from .util import get_store
DEFAULT_TIMEOUT = 30
FIRST_TCP_PUSH_TIMEOUT = 10
@@ -64,9 +66,12 @@ class ReolinkHost:
hass: HomeAssistant,
config: Mapping[str, Any],
options: Mapping[str, Any],
+ config_entry_id: str | None = None,
) -> None:
"""Initialize Reolink Host. Could be either NVR, or Camera."""
self._hass: HomeAssistant = hass
+ self._config_entry_id = config_entry_id
+ self._config = config
self._unique_id: str = ""
def get_aiohttp_session() -> aiohttp.ClientSession:
@@ -95,6 +100,7 @@ class ReolinkHost:
self.firmware_ch_list: list[int | None] = []
self.starting: bool = True
+ self.privacy_mode: bool | None = None
self.credential_errors: int = 0
self.webhook_id: str | None = None
@@ -112,7 +118,9 @@ class ReolinkHost:
self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True)
self._fast_poll_error: bool = False
self._long_poll_task: asyncio.Task | None = None
+ self._lost_subscription_start: bool = False
self._lost_subscription: bool = False
+ self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None
@callback
def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None:
@@ -147,6 +155,14 @@ class ReolinkHost:
f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}"
)
+ store: Store[str] | None = None
+ if self._config_entry_id is not None:
+ store = get_store(self._hass, self._config_entry_id)
+ if self._config.get(CONF_SUPPORTS_PRIVACY_MODE):
+ data = await store.async_load()
+ if data:
+ self._api.set_raw_host_data(data)
+
await self._api.get_host_data()
if self._api.mac_address is None:
@@ -158,6 +174,19 @@ class ReolinkHost:
f"'{self._api.user_level}', only admin users can change camera settings"
)
+ self.privacy_mode = self._api.baichuan.privacy_mode()
+
+ if (
+ store
+ and self._api.supported(None, "privacy_mode")
+ and not self.privacy_mode
+ ):
+ _LOGGER.debug(
+ "Saving raw host data for next reload in case privacy mode is enabled"
+ )
+ data = self._api.get_raw_host_data()
+ await store.async_save(data)
+
onvif_supported = self._api.supported(None, "ONVIF")
self._onvif_push_supported = onvif_supported
self._onvif_long_poll_supported = onvif_supported
@@ -299,7 +328,7 @@ class ReolinkHost:
)
# start long polling if ONVIF push failed immediately
- if not self._onvif_push_supported:
+ if not self._onvif_push_supported and not self._api.baichuan.privacy_mode():
_LOGGER.debug(
"Camera model %s does not support ONVIF push, using ONVIF long polling instead",
self._api.model,
@@ -416,6 +445,11 @@ class ReolinkHost:
wake = True
self.last_wake = time()
+ if self._api.baichuan.privacy_mode():
+ await self._api.baichuan.get_privacy_mode()
+ if self._api.baichuan.privacy_mode():
+ return # API is shutdown, no need to check states
+
await self._api.get_states(cmd_list=self.update_cmd, wake=wake)
async def disconnect(self) -> None:
@@ -459,8 +493,8 @@ class ReolinkHost:
if initial:
raise
# make sure the long_poll_task is always created to try again later
- if not self._lost_subscription:
- self._lost_subscription = True
+ if not self._lost_subscription_start:
+ self._lost_subscription_start = True
_LOGGER.error(
"Reolink %s event long polling subscription lost: %s",
self._api.nvr_name,
@@ -468,15 +502,15 @@ class ReolinkHost:
)
except ReolinkError as err:
# make sure the long_poll_task is always created to try again later
- if not self._lost_subscription:
- self._lost_subscription = True
+ if not self._lost_subscription_start:
+ self._lost_subscription_start = True
_LOGGER.error(
"Reolink %s event long polling subscription lost: %s",
self._api.nvr_name,
err,
)
else:
- self._lost_subscription = False
+ self._lost_subscription_start = False
self._long_poll_task = asyncio.create_task(self._async_long_polling())
async def _async_stop_long_polling(self) -> None:
@@ -543,6 +577,9 @@ class ReolinkHost:
self.unregister_webhook()
await self._api.unsubscribe()
+ if self._api.baichuan.privacy_mode():
+ return # API is shutdown, no need to subscribe
+
try:
if self._onvif_push_supported and not self._api.baichuan.events_active:
await self._renew(SubType.push)
@@ -666,7 +703,9 @@ class ReolinkHost:
try:
channels = await self._api.pull_point_request()
except ReolinkError as ex:
- if not self._long_poll_error:
+ if not self._long_poll_error and self._api.subscribed(
+ SubType.long_poll
+ ):
_LOGGER.error("Error while requesting ONVIF pull point: %s", ex)
await self._api.unsubscribe(sub_type=SubType.long_poll)
self._long_poll_error = True
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index cee044189ea..26198a11594 100644
--- a/homeassistant/components/reolink/icons.json
+++ b/homeassistant/components/reolink/icons.json
@@ -38,9 +38,15 @@
}
},
"person": {
- "default": "mdi:motion-sensor-off",
+ "default": "mdi:account-off",
"state": {
- "on": "mdi:motion-sensor"
+ "on": "mdi:account"
+ }
+ },
+ "cry": {
+ "default": "mdi:emoticon-happy-outline",
+ "state": {
+ "on": "mdi:emoticon-cry-outline"
}
},
"sleep": {
@@ -365,6 +371,12 @@
},
"led": {
"default": "mdi:lightning-bolt-circle"
+ },
+ "privacy_mode": {
+ "default": "mdi:eye",
+ "state": {
+ "on": "mdi:eye-off"
+ }
}
}
},
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index bb6b668368b..fb3c096ee41 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
- "requirements": ["reolink-aio==0.11.6"]
+ "requirements": ["reolink-aio==0.11.9"]
}
diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py
index e4b52c85d45..d8fabfaa3b8 100644
--- a/homeassistant/components/reolink/number.py
+++ b/homeassistant/components/reolink/number.py
@@ -424,6 +424,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_brightness",
cmd_key="GetImage",
+ cmd_id=26,
translation_key="image_brightness",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -437,6 +438,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_contrast",
cmd_key="GetImage",
+ cmd_id=26,
translation_key="image_contrast",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -450,6 +452,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_saturation",
cmd_key="GetImage",
+ cmd_id=26,
translation_key="image_saturation",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -463,6 +466,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_sharpness",
cmd_key="GetImage",
+ cmd_id=26,
translation_key="image_sharpness",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -476,6 +480,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_hue",
cmd_key="GetImage",
+ cmd_id=26,
translation_key="image_hue",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py
index 7a74be2e28c..df8c0269957 100644
--- a/homeassistant/components/reolink/select.py
+++ b/homeassistant/components/reolink/select.py
@@ -80,6 +80,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="day_night_mode",
cmd_key="GetIsp",
+ cmd_id=26,
translation_key="day_night_mode",
entity_category=EntityCategory.CONFIG,
get_options=[mode.name for mode in DayNightEnum],
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index 50163fa1aca..b72e7bbd00d 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -18,6 +18,10 @@
"username": "Username to login to the Reolink device itself. Not the Reolink cloud account.",
"password": "Password to login to the Reolink device itself. Not the Reolink cloud account."
}
+ },
+ "privacy": {
+ "title": "Permission to disable Reolink privacy mode",
+ "description": "Privacy mode is enabled on Reolink device {host}. By pressing SUBMIT, the privacy mode will be disabled to retrieve the necessary information from the Reolink device. You can abort the setup by pressing X and repeat the setup at a time in which privacy mode can be disabled. After this configuration, you are free to enable the privacy mode again using the privacy mode switch entity. During normal startup the privacy mode will not be disabled. Note however that all entities will be marked unavailable as long as the privacy mode is active."
}
},
"error": {
@@ -34,7 +38,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "unique_id_mismatch": "The mac address of the device does not match the previous mac address"
+ "unique_id_mismatch": "The MAC address of the device does not match the previous MAC address"
}
},
"options": {
@@ -54,7 +58,7 @@
"message": "Reolink {service_name} error: config entry not found or not loaded"
},
"service_not_chime": {
- "message": "Reolink play_chime error: {device_name} is not a chime"
+ "message": "Reolink play_chime error: {device_name} is not a Chime"
},
"invalid_parameter": {
"message": "Invalid input parameter: {err}"
@@ -89,6 +93,9 @@
"timeout": {
"message": "Timeout waiting on a response: {err}"
},
+ "unexpected": {
+ "message": "Unexpected Reolink error: {err}"
+ },
"firmware_install_error": {
"message": "Error trying to update Reolink firmware: {err}"
},
@@ -117,10 +124,6 @@
"title": "Reolink firmware update required",
"description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})."
},
- "hdr_switch_deprecated": {
- "title": "Reolink HDR switch deprecated",
- "description": "The Reolink HDR switch entity is deprecated and will be removed in HA 2025.2.0. It has been replaced by a HDR select entity offering options `on`, `off` and `auto`. To remove this issue, please adjust automations accordingly and disable the HDR switch entity."
- },
"hub_switch_deprecated": {
"title": "Reolink Home Hub switches deprecated",
"description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned."
@@ -129,7 +132,7 @@
"services": {
"ptz_move": {
"name": "PTZ move",
- "description": "Move the camera with a specific speed.",
+ "description": "Moves the camera with a specific speed.",
"fields": {
"speed": {
"name": "Speed",
@@ -139,11 +142,11 @@
},
"play_chime": {
"name": "Play chime",
- "description": "Play a ringtone on a chime.",
+ "description": "Plays a ringtone on a Reolink Chime.",
"fields": {
"device_id": {
"name": "Target chime",
- "description": "The chime to play the ringtone on."
+ "description": "The Reolink Chime to play the ringtone on."
},
"ringtone": {
"name": "Ringtone",
@@ -215,6 +218,13 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
+ "cry": {
+ "name": "Baby crying",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
"motion_lens_0": {
"name": "Motion lens 0",
"state": {
@@ -802,6 +812,9 @@
},
"led": {
"name": "LED"
+ },
+ "privacy_mode": {
+ "name": "Privacy mode"
}
}
}
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index b970d04c257..cecb0b0000f 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -208,6 +208,17 @@ SWITCH_ENTITIES = (
),
)
+AVAILABILITY_SWITCH_ENTITIES = (
+ ReolinkSwitchEntityDescription(
+ key="privacy_mode",
+ translation_key="privacy_mode",
+ entity_category=EntityCategory.CONFIG,
+ supported=lambda api, ch: api.supported(ch, "privacy_mode"),
+ value=lambda api, ch: api.baichuan.privacy_mode(ch),
+ method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value),
+ ),
+)
+
NVR_SWITCH_ENTITIES = (
ReolinkNVRSwitchEntityDescription(
key="email",
@@ -267,18 +278,6 @@ CHIME_SWITCH_ENTITIES = (
),
)
-# Can be removed in HA 2025.2.0
-DEPRECATED_HDR = ReolinkSwitchEntityDescription(
- key="hdr",
- cmd_key="GetIsp",
- translation_key="hdr",
- entity_category=EntityCategory.CONFIG,
- entity_registry_enabled_default=False,
- supported=lambda api, ch: api.supported(ch, "HDR"),
- value=lambda api, ch: api.HDR_on(ch) is True,
- method=lambda api, ch, value: api.set_HDR(ch, value),
-)
-
# Can be removed in HA 2025.4.0
DEPRECATED_NVR_SWITCHES = [
ReolinkNVRSwitchEntityDescription(
@@ -356,6 +355,12 @@ async def async_setup_entry(
for entity_description in CHIME_SWITCH_ENTITIES
for chime in reolink_data.host.api.chime_list
)
+ entities.extend(
+ ReolinkAvailabilitySwitchEntity(reolink_data, channel, entity_description)
+ for entity_description in AVAILABILITY_SWITCH_ENTITIES
+ for channel in reolink_data.host.api.channels
+ if entity_description.supported(reolink_data.host.api, channel)
+ )
# Can be removed in HA 2025.4.0
depricated_dict = {}
@@ -367,26 +372,6 @@ async def async_setup_entry(
entity_reg = er.async_get(hass)
reg_entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id)
for entity in reg_entities:
- # Can be removed in HA 2025.2.0
- if entity.domain == "switch" and entity.unique_id.endswith("_hdr"):
- if entity.disabled:
- entity_reg.async_remove(entity.entity_id)
- continue
-
- ir.async_create_issue(
- hass,
- DOMAIN,
- "hdr_switch_deprecated",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="hdr_switch_deprecated",
- )
- entities.extend(
- ReolinkSwitchEntity(reolink_data, channel, DEPRECATED_HDR)
- for channel in reolink_data.host.api.channels
- if DEPRECATED_HDR.supported(reolink_data.host.api, channel)
- )
-
# Can be removed in HA 2025.4.0
if entity.domain == "switch" and entity.unique_id in depricated_dict:
if entity.disabled:
@@ -441,6 +426,15 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity):
self.async_write_ha_state()
+class ReolinkAvailabilitySwitchEntity(ReolinkSwitchEntity):
+ """Switch entity class for Reolink IP cameras which will be available even if API is unavailable."""
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._host.api.camera_online(self._channel)
+
+
class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity):
"""Switch entity class for Reolink NVR features."""
diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py
index f10da8e4b96..a5556b66a33 100644
--- a/homeassistant/components/reolink/util.py
+++ b/homeassistant/components/reolink/util.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
-from typing import Any, ParamSpec, TypeVar
+from typing import TYPE_CHECKING, Any
from reolink_aio.exceptions import (
ApiError,
@@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
-from .host import ReolinkHost
+
+if TYPE_CHECKING:
+ from .host import ReolinkHost
+
+STORAGE_VERSION = 1
type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData]
@@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
return config_entry.runtime_data.host
+def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]:
+ """Return the reolink store."""
+ return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json")
+
+
def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost
) -> tuple[list[str], int | None, bool]:
@@ -87,17 +97,13 @@ def get_device_uid_and_ch(
return (device_uid, ch, is_chime)
-T = TypeVar("T")
-P = ParamSpec("P")
-
-
# Decorators
-def raise_translated_error(
- func: Callable[P, Awaitable[T]],
-) -> Callable[P, Coroutine[Any, Any, T]]:
+def raise_translated_error[**P, R](
+ func: Callable[P, Awaitable[R]],
+) -> Callable[P, Coroutine[Any, Any, R]]:
"""Wrap a reolink-aio function to translate any potential errors."""
- async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> T:
+ async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> R:
"""Try a reolink-aio function and translate any potential errors."""
try:
return await func(*args, **kwargs)
@@ -168,6 +174,10 @@ def raise_translated_error(
translation_placeholders={"err": str(err)},
) from err
except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unexpected",
+ translation_placeholders={"err": str(err)},
+ ) from err
return decorator_raise_translated_error
diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py
index 8d3fc429ce0..8ee09c9ed3d 100644
--- a/homeassistant/components/repairs/__init__.py
+++ b/homeassistant/components/repairs/__init__.py
@@ -12,11 +12,11 @@ from .issue_handler import ConfirmRepairFlow, RepairsFlowManager
from .models import RepairsFlow
__all__ = [
- "ConfirmRepairFlow",
"DOMAIN",
- "repairs_flow_manager",
+ "ConfirmRepairFlow",
"RepairsFlow",
"RepairsFlowManager",
+ "repairs_flow_manager",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py
index 27ddc62a847..16c92d6cd37 100644
--- a/homeassistant/components/repetier/__init__.py
+++ b/homeassistant/components/repetier/__init__.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import track_time_interval
diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py
index c976506d1ba..fa5bd388009 100644
--- a/homeassistant/components/rest/binary_sensor.py
+++ b/homeassistant/components/rest/binary_sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import (
diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py
index 1ca3c55e2b2..ace216e1918 100644
--- a/homeassistant/components/rest/notify.py
+++ b/homeassistant/components/rest/notify.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py
index f7fd8a36113..62ed2d5c5b2 100644
--- a/homeassistant/components/rest/schema.py
+++ b/homeassistant/components/rest/schema.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
HTTP_BASIC_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
TEMPLATE_ENTITY_BASE_SCHEMA,
diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py
index fc6ce8c6749..b95e6dd72b7 100644
--- a/homeassistant/components/rest/sensor.py
+++ b/homeassistant/components/rest/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import (
diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py
index ee93fde35fa..fe3702510af 100644
--- a/homeassistant/components/rest_command/__init__.py
+++ b/homeassistant/components/rest_command/__init__.py
@@ -30,8 +30,8 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py
index 7e86854dbce..85195fb1581 100644
--- a/homeassistant/components/rflink/__init__.py
+++ b/homeassistant/components/rflink/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py
index 29046ba7616..43a7c03c67b 100644
--- a/homeassistant/components/rflink/binary_sensor.py
+++ b/homeassistant/components/rflink/binary_sensor.py
@@ -20,9 +20,8 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, event as evt
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.event as evt
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py
index cc52ea978bd..83eb2915f70 100644
--- a/homeassistant/components/rflink/const.py
+++ b/homeassistant/components/rflink/const.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
CONF_ALIASES = "aliases"
CONF_GROUP_ALIASES = "group_aliases"
diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py
index 695825cf31b..8b21bc9274d 100644
--- a/homeassistant/components/rflink/cover.py
+++ b/homeassistant/components/rflink/cover.py
@@ -14,7 +14,7 @@ from homeassistant.components.cover import (
)
from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py
index 00117140abb..2a5b1ccf8d7 100644
--- a/homeassistant/components/rflink/light.py
+++ b/homeassistant/components/rflink/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py
index 89632ac50b3..027c39da70f 100644
--- a/homeassistant/components/rflink/sensor.py
+++ b/homeassistant/components/rflink/sensor.py
@@ -35,7 +35,7 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py
index 23b93896878..bbbce2b8e9a 100644
--- a/homeassistant/components/rflink/switch.py
+++ b/homeassistant/components/rflink/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py
index 405daa37ec5..c3f61dee026 100644
--- a/homeassistant/components/rfxtrx/device_action.py
+++ b/homeassistant/components/rfxtrx/device_action.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components.device_automation import InvalidDeviceAutomationConfig
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE
from homeassistant.core import Context, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DATA_RFXOBJECT, DOMAIN
diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py
index b5752e366bc..f0cc193023c 100644
--- a/homeassistant/components/rfxtrx/entity.py
+++ b/homeassistant/components/rfxtrx/entity.py
@@ -46,7 +46,7 @@ class RfxtrxEntity(RestoreEntity):
self._attr_device_info = DeviceInfo(
identifiers=_get_identifiers_from_device_tuple(device_id),
model=device.type_string,
- name=f"{device.type_string} {device.id_string}",
+ name=f"{device.type_string} {device_id.id_string}",
)
self._attr_unique_id = "_".join(x for x in device_id)
self._device = device
@@ -54,7 +54,7 @@ class RfxtrxEntity(RestoreEntity):
self._device_id = device_id
# If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to
# group events regardless of their group indices.
- (self._group_id, _, _) = cast(str, device.id_string).partition(":")
+ (self._group_id, _, _) = device_id.id_string.partition(":")
async def async_added_to_hass(self) -> None:
"""Restore RFXtrx device state (ON/OFF)."""
diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py
index 4f8ae9767e2..13f3c012af8 100644
--- a/homeassistant/components/rfxtrx/sensor.py
+++ b/homeassistant/components/rfxtrx/sensor.py
@@ -57,7 +57,7 @@ def _rssi_convert(value: int | None) -> str | None:
"""Rssi is given as dBm value."""
if value is None:
return None
- return f"{value*8-120}"
+ return f"{value * 8 - 120}"
@dataclass(frozen=True)
diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json
index 735ed6c4542..db4efad5bb4 100644
--- a/homeassistant/components/rfxtrx/strings.json
+++ b/homeassistant/components/rfxtrx/strings.json
@@ -54,7 +54,7 @@
"data": {
"off_delay": "Off delay",
"off_delay_enabled": "Enable off delay",
- "data_bit": "Number of data bits",
+ "data_bits": "Number of data bits",
"command_on": "Data bits value for command on",
"command_off": "Data bits value for command off",
"venetian_blind_mode": "Venetian blind mode",
diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py
index 1464cccb5c4..cd17e71f4f0 100644
--- a/homeassistant/components/rfxtrx/switch.py
+++ b/homeassistant/components/rfxtrx/switch.py
@@ -35,8 +35,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool:
isinstance(event.device, rfxtrxmod.LightingDevice)
and not event.device.known_to_be_dimmable
and not event.device.known_to_be_rollershutter
- or isinstance(event.device, rfxtrxmod.RfyDevice)
- )
+ ) or isinstance(event.device, rfxtrxmod.RfyDevice)
async def async_setup_entry(
diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py
index edc084fb57b..8e36f3e85e7 100644
--- a/homeassistant/components/ring/__init__.py
+++ b/homeassistant/components/ring/__init__.py
@@ -2,39 +2,29 @@
from __future__ import annotations
-from dataclasses import dataclass
import logging
from typing import Any, cast
import uuid
-from ring_doorbell import Auth, Ring, RingDevices
+from ring_doorbell import Auth, Ring
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS
-from .coordinator import RingDataCoordinator, RingListenCoordinator
+from .coordinator import (
+ RingConfigEntry,
+ RingData,
+ RingDataCoordinator,
+ RingListenCoordinator,
+)
_LOGGER = logging.getLogger(__name__)
-@dataclass
-class RingData:
- """Class to support type hinting of ring data collection."""
-
- api: Ring
- devices: RingDevices
- devices_coordinator: RingDataCoordinator
- listen_coordinator: RingListenCoordinator
-
-
-type RingConfigEntry = ConfigEntry[RingData]
-
-
def get_auth_user_agent() -> str:
"""Return user-agent for Auth instantiation.
@@ -71,10 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
)
ring = Ring(auth)
- devices_coordinator = RingDataCoordinator(hass, ring)
+ devices_coordinator = RingDataCoordinator(hass, entry, ring)
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
listen_coordinator = RingListenCoordinator(
- hass, ring, listen_credentials, listen_credentials_updater
+ hass, entry, ring, listen_credentials, listen_credentials_updater
)
await devices_coordinator.async_config_entry_first_refresh()
@@ -91,19 +81,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool:
"""Unload Ring entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_config_entry_device(
- hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
+ hass: HomeAssistant, entry: RingConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
return True
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool:
"""Migrate old config entry."""
entry_version = entry.version
entry_minor_version = entry.minor_version
diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py
index 85a916e95cd..2c458985498 100644
--- a/homeassistant/components/ring/binary_sensor.py
+++ b/homeassistant/components/ring/binary_sensor.py
@@ -30,6 +30,9 @@ from .entity import (
async_check_create_deprecated,
)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RingBinarySensorEntityDescription(
diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py
index b9d5cceb373..30600237847 100644
--- a/homeassistant/components/ring/button.py
+++ b/homeassistant/components/ring/button.py
@@ -12,6 +12,10 @@ from . import RingConfigEntry
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
+# Coordinator is used to centralize the data updates
+# Actions restricted to 1 at a time
+PARALLEL_UPDATES = 1
+
BUTTON_DESCRIPTION = ButtonEntityDescription(
key="open_door", translation_key="open_door"
)
diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py
index ccd91c163d6..c1a4e67ffd4 100644
--- a/homeassistant/components/ring/camera.py
+++ b/homeassistant/components/ring/camera.py
@@ -34,6 +34,10 @@ from . import RingConfigEntry
from .coordinator import RingDataCoordinator
from .entity import RingDeviceT, RingEntity, exception_wrap
+# Coordinator is used to centralize the data updates
+# Actions restricted to 1 at a time
+PARALLEL_UPDATES = 1
+
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
MOTION_DETECTION_CAPABILITY = "motion_detection"
diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py
index a1024186349..7d5654947d8 100644
--- a/homeassistant/components/ring/config_flow.py
+++ b/homeassistant/components/ring/config_flow.py
@@ -8,7 +8,6 @@ import uuid
from ring_doorbell import Auth, AuthenticationError, Requires2FAError
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
@@ -24,8 +23,9 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import get_auth_user_agent
from .const import CONF_2FA, CONF_CONFIG_ENTRY_MINOR_VERSION, DOMAIN
@@ -78,7 +78,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
hardware_id: str | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
# Ring has a single config entry per cloud username rather than per device
diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py
index b143fd3dda0..f35a6e10b9f 100644
--- a/homeassistant/components/ring/coordinator.py
+++ b/homeassistant/components/ring/coordinator.py
@@ -1,9 +1,12 @@
"""Data coordinators for the ring integration."""
+from __future__ import annotations
+
from asyncio import TaskGroup
from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
import logging
-from typing import TYPE_CHECKING, Any
+from typing import Any
from ring_doorbell import (
AuthenticationError,
@@ -15,7 +18,7 @@ from ring_doorbell import (
)
from ring_doorbell.listen import RingEventListener
-from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
@@ -29,6 +32,19 @@ from .const import SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
+@dataclass
+class RingData:
+ """Class to support type hinting of ring data collection."""
+
+ api: Ring
+ devices: RingDevices
+ devices_coordinator: RingDataCoordinator
+ listen_coordinator: RingListenCoordinator
+
+
+type RingConfigEntry = ConfigEntry[RingData]
+
+
async def _call_api[*_Ts, _R](
hass: HomeAssistant,
target: Callable[[*_Ts], Coroutine[Any, Any, _R]],
@@ -52,9 +68,12 @@ async def _call_api[*_Ts, _R](
class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
"""Base class for device coordinators."""
+ config_entry: RingConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
+ config_entry: RingConfigEntry,
ring_api: Ring,
) -> None:
"""Initialize my coordinator."""
@@ -63,6 +82,7 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
name="devices",
logger=_LOGGER,
update_interval=SCAN_INTERVAL,
+ config_entry=config_entry,
)
self.ring_api: Ring = ring_api
self.first_call: bool = True
@@ -107,11 +127,12 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Global notifications coordinator."""
- config_entry: config_entries.ConfigEntry
+ config_entry: RingConfigEntry
def __init__(
self,
hass: HomeAssistant,
+ config_entry: RingConfigEntry,
ring_api: Ring,
listen_credentials: dict[str, Any] | None,
listen_credentials_updater: Callable[[dict[str, Any]], None],
@@ -126,9 +147,6 @@ class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol):
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
self._listen_callback_id: int | None = None
- config_entry = config_entries.current_entry.get()
- if TYPE_CHECKING:
- assert config_entry
self.config_entry = config_entry
self.start_timeout = 10
self.config_entry.async_on_unload(self.async_shutdown)
diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py
index b93a7f35322..d48cc35a4f5 100644
--- a/homeassistant/components/ring/entity.py
+++ b/homeassistant/components/ring/entity.py
@@ -2,7 +2,7 @@
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
-from typing import Any, Concatenate, Generic, cast
+from typing import Any, Concatenate, Generic, TypeVar, cast
from ring_doorbell import (
AuthenticationError,
@@ -11,7 +11,6 @@ from ring_doorbell import (
RingGeneric,
RingTimeout,
)
-from typing_extensions import TypeVar
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py
index 71a4bc8aea5..4d7a6277579 100644
--- a/homeassistant/components/ring/event.py
+++ b/homeassistant/components/ring/event.py
@@ -18,6 +18,9 @@ from . import RingConfigEntry
from .coordinator import RingListenCoordinator
from .entity import RingBaseEntity, RingDeviceT
+# Event entity does not perform updates or actions.
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]):
diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py
index 9e29373a3aa..62c5217a89b 100644
--- a/homeassistant/components/ring/light.py
+++ b/homeassistant/components/ring/light.py
@@ -10,7 +10,7 @@ from ring_doorbell import RingStickUpCam
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
@@ -18,6 +18,9 @@ from .entity import RingEntity, exception_wrap
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+# Actions restricted to 1 at a time
+PARALLEL_UPDATES = 1
# It takes a few seconds for the API to correctly return an update indicating
# that the changes have been made. Once we request a change (i.e. a light
diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py
index 91aabb6c800..b920ff7edc7 100644
--- a/homeassistant/components/ring/number.py
+++ b/homeassistant/components/ring/number.py
@@ -20,6 +20,10 @@ from . import RingConfigEntry
from .coordinator import RingDataCoordinator
from .entity import RingDeviceT, RingEntity, refresh_after
+# Coordinator is used to centralize the data updates
+# Actions restricted to 1 at a time
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py
index dee67882857..cf851a113bc 100644
--- a/homeassistant/components/ring/sensor.py
+++ b/homeassistant/components/ring/sensor.py
@@ -41,6 +41,9 @@ from .entity import (
async_check_create_deprecated,
)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py
index b1452f7aeb5..05fa07c39eb 100644
--- a/homeassistant/components/ring/siren.py
+++ b/homeassistant/components/ring/siren.py
@@ -36,6 +36,10 @@ from .entity import (
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+# Actions restricted to 1 at a time
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class RingSirenEntityDescription(
diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json
index 8170ec8e161..8320a3ec47f 100644
--- a/homeassistant/components/ring/strings.json
+++ b/homeassistant/components/ring/strings.json
@@ -6,12 +6,19 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "Your Ring account username.",
+ "password": "Your Ring account password."
}
},
"2fa": {
"title": "Two-factor authentication",
"data": {
"2fa": "Two-factor code"
+ },
+ "data_description": {
+ "2fa": "Account verification code via the method selected in your Ring account settings."
}
},
"reauth_confirm": {
@@ -19,13 +26,19 @@
"description": "The Ring integration needs to re-authenticate your account {username}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::ring::config::step::user::data_description::password%]"
}
},
"reconfigure": {
- "title": "Reconfigure Ring Integration",
+ "title": "Reconfigure Ring integration",
"description": "Will create a new Authorized Device for {username} at ring.com",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::ring::config::step::user::data_description::password%]"
}
}
},
diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py
index 0ac31fec209..cab5654fc5a 100644
--- a/homeassistant/components/ring/switch.py
+++ b/homeassistant/components/ring/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
@@ -27,6 +27,10 @@ from .entity import (
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+# Actions restricted to 1 at a time
+PARALLEL_UPDATES = 1
+
IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2}
diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py
index 72510ea251d..30d2d77dcb4 100644
--- a/homeassistant/components/ripple/sensor.py
+++ b/homeassistant/components/ripple/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py
index 8fd437e7e1d..c3217d9334e 100644
--- a/homeassistant/components/rmvtransport/sensor.py
+++ b/homeassistant/components/rmvtransport/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_NAME, CONF_TIMEOUT, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
@@ -271,11 +271,9 @@ class RMVDepartureData:
if not dest_found:
continue
- if (
- self._lines
- and journey["number"] not in self._lines
- or journey["minutes"] < self._time_offset
- ):
+ if (self._lines and journey["number"] not in self._lines) or journey[
+ "minutes"
+ ] < self._time_offset:
continue
for attr in ("direction", "departure_time", "product", "minutes"):
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index bc82aadffed..b383c1acfd7 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -22,12 +22,13 @@ from roborock.version_a01_apis import RoborockMqttClientA01
from roborock.web_api import RoborockApiClient
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_USERNAME
+from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS
from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01
+from .roborock_storage import async_remove_map_storage
SCAN_INTERVAL = timedelta(seconds=30)
@@ -117,13 +118,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
)
valid_coordinators = RoborockCoordinators(v1_coords, a01_coords)
- async def on_unload() -> None:
- release_tasks = set()
- for coordinator in valid_coordinators.values():
- release_tasks.add(coordinator.release())
- await asyncio.gather(*release_tasks)
+ async def on_stop(_: Any) -> None:
+ _LOGGER.debug("Shutting down roborock")
+ await asyncio.gather(
+ *(
+ coordinator.async_shutdown()
+ for coordinator in valid_coordinators.values()
+ )
+ )
- entry.async_on_unload(on_unload)
+ entry.async_on_unload(
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP,
+ on_stop,
+ )
+ )
entry.runtime_data = valid_coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -205,18 +214,10 @@ async def setup_device_v1(
coordinator = RoborockDataUpdateCoordinator(
hass, device, networking, product_info, mqtt_client, home_data_rooms
)
- # Verify we can communicate locally - if we can't, switch to cloud api
- await coordinator.verify_api()
- coordinator.api.is_available = True
- try:
- await coordinator.get_maps()
- except RoborockException as err:
- _LOGGER.warning("Failed to get map data")
- _LOGGER.debug(err)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady as ex:
- await coordinator.release()
+ await coordinator.async_shutdown()
if isinstance(coordinator.api, RoborockMqttClientV1):
_LOGGER.warning(
"Not setting up %s because the we failed to get data for the first time using the online client. "
@@ -267,3 +268,8 @@ async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> No
"""Handle options update."""
# Reload entry to update data
await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None:
+ """Handle removal of an entry."""
+ await async_remove_map_storage(hass, entry.entry_id)
diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py
index 834b25965c3..cc8d34fbadc 100644
--- a/homeassistant/components/roborock/const.py
+++ b/homeassistant/components/roborock/const.py
@@ -49,3 +49,7 @@ IMAGE_CACHE_INTERVAL = 90
MAP_SLEEP = 3
GET_MAPS_SERVICE_NAME = "get_maps"
+MAP_FILE_FORMAT = "PNG"
+MAP_FILENAME_SUFFIX = ".png"
+SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position"
+GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position"
diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py
index fe592074f71..8860a5c1f43 100644
--- a/homeassistant/components/roborock/coordinator.py
+++ b/homeassistant/components/roborock/coordinator.py
@@ -2,10 +2,11 @@
from __future__ import annotations
+import asyncio
from datetime import timedelta
import logging
-from propcache import cached_property
+from propcache.api import cached_property
from roborock import HomeDataRoom
from roborock.code_mappings import RoborockCategory
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
@@ -16,6 +17,7 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -26,6 +28,7 @@ from homeassistant.util import slugify
from .const import DOMAIN
from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo
+from .roborock_storage import RoborockMapStorage
SCAN_INTERVAL = timedelta(seconds=30)
@@ -35,6 +38,8 @@ _LOGGER = logging.getLogger(__name__)
class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
"""Class to manage fetching data from the API."""
+ config_entry: ConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
@@ -72,8 +77,31 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# Maps from map flag to map name
self.maps: dict[int, RoborockMapInfo] = {}
self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms}
+ self.map_storage = RoborockMapStorage(
+ hass, self.config_entry.entry_id, slugify(self.duid)
+ )
- async def verify_api(self) -> None:
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ # Verify we can communicate locally - if we can't, switch to cloud api
+ await self._verify_api()
+ self.api.is_available = True
+
+ try:
+ maps = await self.api.get_multi_maps_list()
+ except RoborockException as err:
+ raise UpdateFailed("Failed to get map data: {err}") from err
+ # Rooms names populated later with calls to `set_current_map_rooms` for each map
+ self.maps = {
+ roborock_map.mapFlag: RoborockMapInfo(
+ flag=roborock_map.mapFlag,
+ name=roborock_map.name or f"Map {roborock_map.mapFlag}",
+ rooms={},
+ )
+ for roborock_map in (maps.map_info if (maps and maps.map_info) else ())
+ }
+
+ async def _verify_api(self) -> None:
"""Verify that the api is reachable. If it is not, switch clients."""
if isinstance(self.api, RoborockLocalClientV1):
try:
@@ -89,19 +117,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# Right now this should never be called if the cloud api is the primary api,
# but in the future if it is, a new else should be added.
- async def release(self) -> None:
- """Disconnect from API."""
- await self.api.async_release()
- await self.cloud_api.async_release()
+ async def async_shutdown(self) -> None:
+ """Shutdown the coordinator."""
+ await super().async_shutdown()
+ await asyncio.gather(
+ self.map_storage.flush(),
+ self.api.async_release(),
+ self.cloud_api.async_release(),
+ )
async def _update_device_prop(self) -> None:
"""Update device properties."""
- device_prop = await self.api.get_prop()
- if device_prop:
- if self.roborock_device_info.props:
- self.roborock_device_info.props.update(device_prop)
- else:
- self.roborock_device_info.props = device_prop
+ if (device_prop := await self.api.get_prop()) is not None:
+ self.roborock_device_info.props.update(device_prop)
async def _async_update_data(self) -> DeviceProp:
"""Update data via library."""
@@ -111,7 +139,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# Set the new map id from the updated device props
self._set_current_map()
# Get the rooms for that map id.
- await self.get_rooms()
+ await self.set_current_map_rooms()
except RoborockException as ex:
raise UpdateFailed(ex) from ex
return self.roborock_device_info.props
@@ -127,27 +155,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.roborock_device_info.props.status.map_status - 3
) // 4
- async def get_maps(self) -> None:
- """Add a map to the coordinators mapping."""
- maps = await self.api.get_multi_maps_list()
- if maps and maps.map_info:
- for roborock_map in maps.map_info:
- self.maps[roborock_map.mapFlag] = RoborockMapInfo(
- flag=roborock_map.mapFlag, name=roborock_map.name, rooms={}
- )
-
- async def get_rooms(self) -> None:
- """Get all of the rooms for the current map."""
+ async def set_current_map_rooms(self) -> None:
+ """Fetch all of the rooms for the current map and set on RoborockMapInfo."""
# The api is only able to access rooms for the currently selected map
# So it is important this is only called when you have the map you care
# about selected.
- if self.current_map in self.maps:
- iot_rooms = await self.api.get_room_mapping()
- if iot_rooms is not None:
- for room in iot_rooms:
- self.maps[self.current_map].rooms[room.segment_id] = (
- self._home_data_rooms.get(room.iot_id, "Unknown")
- )
+ if self.current_map is None or self.current_map not in self.maps:
+ return
+ room_mapping = await self.api.get_room_mapping()
+ self.maps[self.current_map].rooms = {
+ room.segment_id: self._home_data_rooms.get(room.iot_id, "Unknown")
+ for room in room_mapping or ()
+ }
@cached_property
def duid(self) -> str:
@@ -212,8 +231,9 @@ class RoborockDataUpdateCoordinatorA01(
) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]:
return await self.api.update_values(self.request_protocols)
- async def release(self) -> None:
- """Disconnect from API."""
+ async def async_shutdown(self) -> None:
+ """Shutdown the coordinator on config entry unload."""
+ await super().async_shutdown()
await self.api.async_release()
@cached_property
diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json
index c7df6d35460..6a96b04e12e 100644
--- a/homeassistant/components/roborock/icons.json
+++ b/homeassistant/components/roborock/icons.json
@@ -61,6 +61,9 @@
"total_cleaning_area": {
"default": "mdi:texture-box"
},
+ "total_cleaning_count": {
+ "default": "mdi:counter"
+ },
"vacuum_error": {
"default": "mdi:alert-circle"
},
@@ -121,6 +124,12 @@
"services": {
"get_maps": {
"service": "mdi:floor-plan"
+ },
+ "set_vacuum_goto_position": {
+ "service": "mdi:map-marker"
+ },
+ "get_vacuum_current_position": {
+ "service": "mdi:map-marker"
}
}
}
diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py
index ee48656290f..b4776c27164 100644
--- a/homeassistant/components/roborock/image.py
+++ b/homeassistant/components/roborock/image.py
@@ -1,27 +1,33 @@
"""Support for Roborock image."""
import asyncio
+from collections.abc import Callable
from datetime import datetime
import io
-from itertools import chain
from roborock import RoborockCommand
from vacuum_map_parser_base.config.color import ColorsPalette
-from vacuum_map_parser_base.config.drawable import Drawable
from vacuum_map_parser_base.config.image_config import ImageConfig
from vacuum_map_parser_base.config.size import Sizes
from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
from homeassistant.components.image import ImageEntity
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import RoborockConfigEntry
-from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP
+from .const import (
+ DEFAULT_DRAWABLES,
+ DOMAIN,
+ DRAWABLES,
+ IMAGE_CACHE_INTERVAL,
+ MAP_FILE_FORMAT,
+ MAP_SLEEP,
+)
from .coordinator import RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
@@ -38,17 +44,35 @@ async def async_setup_entry(
for drawable, default_value in DEFAULT_DRAWABLES.items()
if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value)
]
- entities = list(
- chain.from_iterable(
- await asyncio.gather(
- *(
- create_coordinator_maps(coord, drawables)
- for coord in config_entry.runtime_data.v1
- )
- )
- )
+ parser = RoborockMapDataParser(
+ ColorsPalette(), Sizes(), drawables, ImageConfig(), []
+ )
+
+ def parse_image(map_bytes: bytes) -> bytes | None:
+ parsed_map = parser.parse(map_bytes)
+ if parsed_map.image is None:
+ return None
+ img_byte_arr = io.BytesIO()
+ parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT)
+ return img_byte_arr.getvalue()
+
+ await asyncio.gather(
+ *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1)
+ )
+ async_add_entities(
+ (
+ RoborockMap(
+ config_entry,
+ f"{coord.duid_slug}_map_{map_info.name}",
+ coord,
+ map_info.flag,
+ map_info.name,
+ parse_image,
+ )
+ for coord in config_entry.runtime_data.v1
+ for map_info in coord.maps.values()
+ ),
)
- async_add_entities(entities)
class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
@@ -56,39 +80,27 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
_attr_has_entity_name = True
image_last_updated: datetime
+ _attr_name: str
def __init__(
self,
+ config_entry: ConfigEntry,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator,
map_flag: int,
- starting_map: bytes,
map_name: str,
- drawables: list[Drawable],
+ parser: Callable[[bytes], bytes | None],
) -> None:
"""Initialize a Roborock map."""
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
ImageEntity.__init__(self, coordinator.hass)
+ self.config_entry = config_entry
self._attr_name = map_name
- self.parser = RoborockMapDataParser(
- ColorsPalette(), Sizes(), drawables, ImageConfig(), []
- )
- self._attr_image_last_updated = dt_util.utcnow()
+ self.parser = parser
self.map_flag = map_flag
- try:
- self.cached_map = self._create_image(starting_map)
- except HomeAssistantError:
- # If we failed to update the image on init,
- # we set cached_map to empty bytes
- # so that we are unavailable and can try again later.
- self.cached_map = b""
+ self.cached_map = b""
self._attr_entity_category = EntityCategory.DIAGNOSTIC
- @property
- def available(self) -> bool:
- """Determines if the entity is available."""
- return self.cached_map != b""
-
@property
def is_selected(self) -> bool:
"""Return if this map is the currently selected map."""
@@ -107,6 +119,14 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
and bool(self.coordinator.roborock_device_info.props.status.in_cleaning)
)
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass load any previously cached maps from disk."""
+ await super().async_added_to_hass()
+ content = await self.coordinator.map_storage.async_load_map(self.map_flag)
+ self.cached_map = content or b""
+ self._attr_image_last_updated = dt_util.utcnow()
+ self.async_write_ha_state()
+
def _handle_coordinator_update(self) -> None:
# Bump last updated every third time the coordinator runs, so that async_image
# will be called and we will evaluate on the new coordinator data if we should
@@ -121,50 +141,42 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
"""Update the image if it is not cached."""
if self.is_map_valid():
response = await asyncio.gather(
- *(self.cloud_api.get_map_v1(), self.coordinator.get_rooms()),
+ *(
+ self.cloud_api.get_map_v1(),
+ self.coordinator.set_current_map_rooms(),
+ ),
return_exceptions=True,
)
- if not isinstance(response[0], bytes):
+ if (
+ not isinstance(response[0], bytes)
+ or (content := self.parser(response[0])) is None
+ ):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="map_failure",
)
- map_data = response[0]
- self.cached_map = self._create_image(map_data)
+ if self.cached_map != content:
+ self.cached_map = content
+ await self.coordinator.map_storage.async_save_map(
+ self.map_flag,
+ content,
+ )
return self.cached_map
- def _create_image(self, map_bytes: bytes) -> bytes:
- """Create an image using the map parser."""
- parsed_map = self.parser.parse(map_bytes)
- if parsed_map.image is None:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="map_failure",
- )
- img_byte_arr = io.BytesIO()
- parsed_map.image.data.save(img_byte_arr, format="PNG")
- return img_byte_arr.getvalue()
-
-async def create_coordinator_maps(
- coord: RoborockDataUpdateCoordinator, drawables: list[Drawable]
-) -> list[RoborockMap]:
+async def refresh_coordinators(
+ hass: HomeAssistant, coord: RoborockDataUpdateCoordinator
+) -> None:
"""Get the starting map information for all maps for this device.
The following steps must be done synchronously.
Only one map can be loaded at a time per device.
"""
- entities = []
cur_map = coord.current_map
# This won't be None at this point as the coordinator will have run first.
assert cur_map is not None
- # Sort the maps so that we start with the current map and we can skip the
- # load_multi_map call.
- maps_info = sorted(
- coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True
- )
- for map_flag, map_info in maps_info:
- # Load the map - so we can access it with get_map_v1
+ map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True)
+ for map_flag in map_flags:
if map_flag != cur_map:
# Only change the map and sleep if we have multiple maps.
await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag])
@@ -172,27 +184,11 @@ async def create_coordinator_maps(
# We cannot get the map until the roborock servers fully process the
# map change.
await asyncio.sleep(MAP_SLEEP)
- # Get the map data
- map_update = await asyncio.gather(
- *[coord.cloud_api.get_map_v1(), coord.get_rooms()], return_exceptions=True
- )
- # If we fail to get the map, we should set it to empty byte,
- # still create it, and set it as unavailable.
- api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b""
- entities.append(
- RoborockMap(
- f"{slugify(coord.duid)}_map_{map_info.name}",
- coord,
- map_flag,
- api_data,
- map_info.name,
- drawables,
- )
- )
+ await coord.set_current_map_rooms()
+
if len(coord.maps) != 1:
# Set the map back to the map the user previously had selected so that it
# does not change the end user's app.
# Only needs to happen when we changed maps above.
await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map])
coord.current_map = cur_map
- return entities
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index bb89ecedbe3..db2654d4baa 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -1,13 +1,13 @@
{
"domain": "roborock",
"name": "Roborock",
- "codeowners": ["@Lash-L"],
+ "codeowners": ["@Lash-L", "@allenporter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": [
- "python-roborock==2.8.4",
+ "python-roborock==2.11.1",
"vacuum-map-parser-roborock==0.1.2"
]
}
diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py
new file mode 100644
index 00000000000..8a469b0a38e
--- /dev/null
+++ b/homeassistant/components/roborock/roborock_storage.py
@@ -0,0 +1,95 @@
+"""Roborock storage."""
+
+import logging
+from pathlib import Path
+import shutil
+
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN, MAP_FILENAME_SUFFIX
+
+_LOGGER = logging.getLogger(__name__)
+
+STORAGE_PATH = f".storage/{DOMAIN}"
+MAPS_PATH = "maps"
+
+
+def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path:
+ return Path(hass.config.path(STORAGE_PATH)) / entry_id
+
+
+class RoborockMapStorage:
+ """Store and retrieve maps for a Roborock device.
+
+ An instance of RoborockMapStorage is created for each device and manages
+ local storage of maps for that device.
+ """
+
+ def __init__(self, hass: HomeAssistant, entry_id: str, device_id_slug: str) -> None:
+ """Initialize RoborockMapStorage."""
+ self._hass = hass
+ self._path_prefix = (
+ _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug
+ )
+ self._write_queue: dict[int, bytes] = {}
+
+ async def async_load_map(self, map_flag: int) -> bytes | None:
+ """Load maps from disk."""
+ filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}"
+ return await self._hass.async_add_executor_job(self._load_map, filename)
+
+ def _load_map(self, filename: Path) -> bytes | None:
+ """Load maps from disk."""
+ if not filename.exists():
+ return None
+ try:
+ return filename.read_bytes()
+ except OSError as err:
+ _LOGGER.debug("Unable to read map file: %s %s", filename, err)
+ return None
+
+ async def async_save_map(self, map_flag: int, content: bytes) -> None:
+ """Save the map to a pending write queue."""
+ self._write_queue[map_flag] = content
+
+ async def flush(self) -> None:
+ """Flush all maps to disk."""
+ _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue))
+
+ queue = self._write_queue.copy()
+
+ def _flush_all() -> None:
+ for map_flag, content in queue.items():
+ filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}"
+ self._save_map(filename, content)
+
+ await self._hass.async_add_executor_job(_flush_all)
+ self._write_queue.clear()
+
+ def _save_map(self, filename: Path, content: bytes) -> None:
+ """Write the map to disk."""
+ _LOGGER.debug("Saving map to disk: %s", filename)
+ try:
+ filename.parent.mkdir(parents=True, exist_ok=True)
+ except OSError as err:
+ _LOGGER.error("Unable to create map directory: %s %s", filename, err)
+ return
+ try:
+ filename.write_bytes(content)
+ except OSError as err:
+ _LOGGER.error("Unable to write map file: %s %s", filename, err)
+
+
+async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None:
+ """Remove all map storage associated with a config entry."""
+
+ def remove(path_prefix: Path) -> None:
+ try:
+ if path_prefix.exists():
+ shutil.rmtree(path_prefix, ignore_errors=True)
+ except OSError as err:
+ _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err)
+
+ path_prefix = _storage_path_prefix(hass, entry_id)
+ _LOGGER.debug("Removing maps from disk store: %s", path_prefix)
+ await hass.async_add_executor_job(remove, path_prefix)
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
index 47849ed5cc5..e01a03d7720 100644
--- a/homeassistant/components/roborock/sensor.py
+++ b/homeassistant/components/roborock/sensor.py
@@ -24,6 +24,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
+ SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
@@ -112,6 +113,13 @@ SENSOR_DESCRIPTIONS = [
value_fn=lambda data: data.clean_summary.clean_time,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ RoborockSensorDescription(
+ key="total_cleaning_count",
+ translation_key="total_cleaning_count",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda data: data.clean_summary.clean_count,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
RoborockSensorDescription(
key="status",
device_class=SensorDeviceClass.ENUM,
diff --git a/homeassistant/components/roborock/services.yaml b/homeassistant/components/roborock/services.yaml
index 18de5c98c7b..eebda66fac7 100644
--- a/homeassistant/components/roborock/services.yaml
+++ b/homeassistant/components/roborock/services.yaml
@@ -1,4 +1,28 @@
get_maps:
target:
entity:
+ integration: roborock
+ domain: vacuum
+set_vacuum_goto_position:
+ target:
+ entity:
+ integration: roborock
+ domain: vacuum
+ fields:
+ x:
+ example: 27500
+ required: true
+ selector:
+ text:
+ type: number
+ y:
+ example: 32000
+ required: true
+ selector:
+ text:
+ type: number
+get_vacuum_current_position:
+ target:
+ entity:
+ integration: roborock
domain: vacuum
diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json
index 8c66f6ab986..7005344614c 100644
--- a/homeassistant/components/roborock/strings.json
+++ b/homeassistant/components/roborock/strings.json
@@ -228,6 +228,9 @@
"total_cleaning_area": {
"name": "Total cleaning area"
},
+ "total_cleaning_count": {
+ "name": "Total cleaning count"
+ },
"vacuum_error": {
"name": "Vacuum error",
"state": {
@@ -434,6 +437,24 @@
"get_maps": {
"name": "Get maps",
"description": "Get the map and room information of your device."
+ },
+ "set_vacuum_goto_position": {
+ "name": "Go to position",
+ "description": "Send the vacuum to a specific position.",
+ "fields": {
+ "x": {
+ "name": "X-coordinate",
+ "description": "Coordinates are relative to the dock. x=25500,y=25500 is the dock position."
+ },
+ "y": {
+ "name": "Y-coordinate",
+ "description": "[%key:component::roborock::services::set_vacuum_goto_position::fields::x::description%]"
+ }
+ }
+ },
+ "get_vacuum_current_position": {
+ "name": "Get current position",
+ "description": "Get the current position of the vacuum."
}
}
}
diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py
index d3413bd7cbd..7582dadad16 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -6,6 +6,7 @@ from typing import Any
from roborock.code_mappings import RoborockStateCode
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand
+import voluptuous as vol
from homeassistant.components.vacuum import (
StateVacuumEntity,
@@ -13,13 +14,20 @@ from homeassistant.components.vacuum import (
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
-from homeassistant.helpers import entity_platform
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import RoborockConfigEntry
-from .const import DOMAIN, GET_MAPS_SERVICE_NAME
+from .const import (
+ DOMAIN,
+ GET_MAPS_SERVICE_NAME,
+ GET_VACUUM_CURRENT_POSITION_SERVICE_NAME,
+ SET_VACUUM_GOTO_POSITION_SERVICE_NAME,
+)
from .coordinator import RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
+from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes
STATE_CODE_TO_STATE = {
RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting"
@@ -69,6 +77,25 @@ async def async_setup_entry(
supports_response=SupportsResponse.ONLY,
)
+ platform.async_register_entity_service(
+ GET_VACUUM_CURRENT_POSITION_SERVICE_NAME,
+ None,
+ RoborockVacuum.get_vacuum_current_position.__name__,
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ platform.async_register_entity_service(
+ SET_VACUUM_GOTO_POSITION_SERVICE_NAME,
+ cv.make_entity_service_schema(
+ {
+ vol.Required("x"): vol.Coerce(int),
+ vol.Required("y"): vol.Coerce(int),
+ },
+ ),
+ RoborockVacuum.async_set_vacuum_goto_position.__name__,
+ supports_response=SupportsResponse.NONE,
+ )
+
class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
"""General Representation of a Roborock vacuum."""
@@ -158,6 +185,10 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
[self._device_status.get_fan_speed_code(fan_speed)],
)
+ async def async_set_vacuum_goto_position(self, x: int, y: int) -> None:
+ """Send vacuum to a specific target point."""
+ await self.send(RoborockCommand.APP_GOTO_TARGET, [x, y])
+
async def async_send_command(
self,
command: str,
@@ -174,3 +205,21 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values()
]
}
+
+ async def get_vacuum_current_position(self) -> ServiceResponse:
+ """Get the current position of the vacuum from the map."""
+
+ map_data = await self.coordinator.cloud_api.get_map_v1()
+ if not isinstance(map_data, bytes):
+ raise HomeAssistantError("Failed to retrieve map data.")
+ parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), [])
+ parsed_map = parser.parse(map_data)
+ robot_position = parsed_map.vacuum_position
+
+ if robot_position is None:
+ raise HomeAssistantError("Robot position not found")
+
+ return {
+ "x": robot_position.x,
+ "y": robot_position.y,
+ }
diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py
index a06226d22ee..20ae0708c15 100644
--- a/homeassistant/components/rocketchat/notify.py
+++ b/homeassistant/components/rocketchat/notify.py
@@ -19,7 +19,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_PASSWORD, CONF_ROOM, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py
index bc0092d6953..2fb016b5467 100644
--- a/homeassistant/components/roku/config_flow.py
+++ b/homeassistant/components/roku/config_flow.py
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
from rokuecp import Roku, RokuError
import voluptuous as vol
-from homeassistant.components import ssdp, zeroconf
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
@@ -19,6 +18,12 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import RokuConfigEntry
from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN
@@ -117,7 +122,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=info["title"], data=user_input)
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by homekit discovery."""
@@ -147,12 +152,12 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
host = urlparse(discovery_info.ssdp_location).hostname
- name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
- serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
+ name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]
+ serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL]
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json
index bd47585db1b..04348bc3bfb 100644
--- a/homeassistant/components/roku/strings.json
+++ b/homeassistant/components/roku/strings.json
@@ -23,7 +23,7 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "wrong_device": "This Roku device does not match the existing device id. Please make sure you entered the correct host information."
+ "wrong_device": "This Roku device does not match the existing device ID. Please make sure you entered the correct host information."
}
},
"options": {
diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py
index e571ff41c9a..48558cd98c7 100644
--- a/homeassistant/components/romy/config_flow.py
+++ b/homeassistant/components/romy/config_flow.py
@@ -5,10 +5,10 @@ from __future__ import annotations
import romy
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
@@ -84,7 +84,7 @@ class RomyConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py
index d040074246a..b7d259e3131 100644
--- a/homeassistant/components/roomba/config_flow.py
+++ b/homeassistant/components/roomba/config_flow.py
@@ -11,7 +11,6 @@ from roombapy.discovery import RoombaDiscovery
from roombapy.getpassword import RoombaPassword
import voluptuous as vol
-from homeassistant.components import dhcp, zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -20,6 +19,8 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout
from .const import (
@@ -95,7 +96,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN):
return RoombaOptionsFlowHandler()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
return await self._async_step_discovery(
@@ -103,7 +104,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
return await self._async_step_discovery(
diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py
index d55a260e53a..ae5577da4e4 100644
--- a/homeassistant/components/roomba/entity.py
+++ b/homeassistant/components/roomba/entity.py
@@ -3,10 +3,10 @@
from __future__ import annotations
from homeassistant.const import ATTR_CONNECTIONS
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import roomba_reported_state
from .const import DOMAIN
diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py
index b896f6775ae..3421cbf646c 100644
--- a/homeassistant/components/roon/config_flow.py
+++ b/homeassistant/components/roon/config_flow.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
AUTHENTICATE_TIMEOUT,
diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py
index 92094b0b608..2c9824d0628 100644
--- a/homeassistant/components/route53/__init__.py
+++ b/homeassistant/components/route53/__init__.py
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_time_interval
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py
index 89624c922e6..98d0e1bf790 100644
--- a/homeassistant/components/rss_feed_template/__init__.py
+++ b/homeassistant/components/rss_feed_template/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py
index 654288927d3..70fe7919edb 100644
--- a/homeassistant/components/rtorrent/sensor.py
+++ b/homeassistant/components/rtorrent/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py
index fedf5d8c686..65fbd89e203 100644
--- a/homeassistant/components/russound_rio/__init__.py
+++ b/homeassistant/components/russound_rio/__init__.py
@@ -40,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
try:
await client.connect()
+ await client.load_zone_source_metadata()
except RUSSOUND_RIO_EXCEPTIONS as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py
index f7f2e5b1d00..edf542b5de2 100644
--- a/homeassistant/components/russound_rio/config_flow.py
+++ b/homeassistant/components/russound_rio/config_flow.py
@@ -13,8 +13,9 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
)
-from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
@@ -33,6 +34,53 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self.data: dict[str, Any] = {}
+
+ async def async_step_zeroconf(
+ self, discovery_info: ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle zeroconf discovery."""
+ self.data[CONF_HOST] = host = discovery_info.host
+ self.data[CONF_PORT] = port = discovery_info.port or 9621
+
+ client = RussoundClient(RussoundTcpConnectionHandler(host, port))
+ try:
+ await client.connect()
+ controller = client.controllers[1]
+ await client.disconnect()
+ except RUSSOUND_RIO_EXCEPTIONS:
+ return self.async_abort(reason="cannot_connect")
+
+ await self.async_set_unique_id(controller.mac_address)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: host})
+
+ self.data[CONF_NAME] = controller.controller_type
+
+ self.context["title_placeholders"] = {
+ "name": self.data[CONF_NAME],
+ }
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm discovery."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title=self.data[CONF_NAME],
+ data={CONF_HOST: self.data[CONF_HOST], CONF_PORT: self.data[CONF_PORT]},
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={
+ "name": self.data[CONF_NAME],
+ },
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -51,7 +99,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Could not connect to Russound RIO")
errors["base"] = "cannot_connect"
else:
- await self.async_set_unique_id(controller.mac_address)
+ await self.async_set_unique_id(
+ controller.mac_address, raise_on_progress=False
+ )
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(
@@ -78,26 +128,3 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=DATA_SCHEMA,
)
return await self.async_step_user(user_input)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Attempt to import the existing configuration."""
- self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
- host = import_data[CONF_HOST]
- port = import_data.get(CONF_PORT, 9621)
-
- # Connection logic is repeated here since this method will be removed in future releases
- client = RussoundClient(RussoundTcpConnectionHandler(host, port))
- try:
- await client.connect()
- controller = client.controllers[1]
- await client.disconnect()
- except RUSSOUND_RIO_EXCEPTIONS:
- _LOGGER.exception("Could not connect to Russound RIO")
- return self.async_abort(
- reason="cannot_connect", description_placeholders={}
- )
- else:
- await self.async_set_unique_id(controller.mac_address)
- self._abort_if_unique_id_configured()
- data = {CONF_HOST: host, CONF_PORT: port}
- return self.async_create_entry(title=controller.controller_type, data=data)
diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py
index a142ba8641d..9647c419da0 100644
--- a/homeassistant/components/russound_rio/const.py
+++ b/homeassistant/components/russound_rio/const.py
@@ -3,9 +3,6 @@
import asyncio
from aiorussound import CommandError
-from aiorussound.const import FeatureFlag
-
-from homeassistant.components.media_player import MediaPlayerEntityFeature
DOMAIN = "russound_rio"
@@ -15,7 +12,3 @@ RUSSOUND_RIO_EXCEPTIONS = (
TimeoutError,
asyncio.CancelledError,
)
-
-MP_FEATURES_BY_FLAG = {
- FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON: MediaPlayerEntityFeature.VOLUME_MUTE
-}
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index f1d3671970d..f91406e8a4b 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -7,5 +7,6 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
- "requirements": ["aiorussound==4.1.1"]
+ "requirements": ["aiorussound==4.4.0"],
+ "zeroconf": ["_rio._tcp.local."]
}
diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py
index 02467731ec3..346f4903f6a 100644
--- a/homeassistant/components/russound_rio/media_player.py
+++ b/homeassistant/components/russound_rio/media_player.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import datetime as dt
import logging
from typing import TYPE_CHECKING
@@ -18,15 +19,10 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.core import HomeAssistant
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__)
@@ -34,54 +30,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Russound RIO platform."""
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
- if (
- result["type"] is FlowResultType.CREATE_ENTRY
- or result["reason"] == "single_instance_allowed"
- ):
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Russound RIO",
- },
- )
- return
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{result['reason']}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Russound RIO",
- },
- )
-
-
async def async_setup_entry(
hass: HomeAssistant,
entry: RussoundConfigEntry,
@@ -106,9 +54,11 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
+ | MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
+ | MediaPlayerEntityFeature.SEEK
)
def __init__(
@@ -121,9 +71,6 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
self._sources = sources
self._attr_name = _zone.name
self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}"
- for flag, feature in MP_FEATURES_BY_FLAG.items():
- if flag in self._client.supported_features:
- self._attr_supported_features |= feature
@property
def _zone(self) -> ZoneControlSurface:
@@ -193,6 +140,21 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
"""Image url of current playing media."""
return self._source.cover_art_url
+ @property
+ def media_duration(self) -> int | None:
+ """Duration of the current media."""
+ return self._source.track_time
+
+ @property
+ def media_position(self) -> int | None:
+ """Position of the current media."""
+ return self._source.play_time
+
+ @property
+ def media_position_updated_at(self) -> dt.datetime:
+ """Last time the media position was updated."""
+ return self._source.position_last_updated
+
@property
def volume_level(self) -> float:
"""Volume level of the media player (0..1).
@@ -202,6 +164,11 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
"""
return self._zone.volume / 50.0
+ @property
+ def is_volume_muted(self) -> bool:
+ """Return whether zone is muted."""
+ return self._zone.is_mute
+
@command
async def async_turn_off(self) -> None:
"""Turn off the zone."""
@@ -236,3 +203,21 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
async def async_volume_down(self) -> None:
"""Step the volume down."""
await self._zone.volume_down()
+
+ @command
+ async def async_mute_volume(self, mute: bool) -> None:
+ """Mute the media player."""
+ if FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON in self._client.supported_features:
+ if mute:
+ await self._zone.mute()
+ else:
+ await self._zone.unmute()
+ return
+
+ if mute != self.is_volume_muted:
+ await self._zone.toggle_mute()
+
+ @command
+ async def async_media_seek(self, position: float) -> None:
+ """Seek to a position in the current media."""
+ await self._zone.set_seek_time(int(position))
diff --git a/homeassistant/components/russound_rio/quality_scale.yaml b/homeassistant/components/russound_rio/quality_scale.yaml
index bd511802467..02b1eaa6aae 100644
--- a/homeassistant/components/russound_rio/quality_scale.yaml
+++ b/homeassistant/components/russound_rio/quality_scale.yaml
@@ -57,7 +57,7 @@ rules:
status: exempt
comment: |
This integration doesn't have enough / noisy entities that warrant being disabled by default.
- discovery: todo
+ discovery: done
stale-devices: todo
diagnostics: done
exception-translations: done
@@ -67,8 +67,11 @@ rules:
There are no entities that require icons.
reconfiguration-flow: done
dynamic-devices: todo
- discovery-update-info: todo
- repair-issues: done
+ discovery-update-info: done
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
docs-use-cases: done
docs-supported-devices: done
docs-supported-functions: todo
diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json
index 93544064e20..eba66856302 100644
--- a/homeassistant/components/russound_rio/strings.json
+++ b/homeassistant/components/russound_rio/strings.json
@@ -15,6 +15,9 @@
"port": "The port of the Russound controller."
}
},
+ "discovery_confirm": {
+ "description": "Do you want to set up {name}?"
+ },
"reconfigure": {
"description": "Reconfigure your Russound controller.",
"data": {
@@ -34,21 +37,7 @@
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "wrong_device": "This Russound controller does not match the existing device id. Please make sure you entered the correct IP address."
- }
- },
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The {integration_title} YAML configuration import cannot connect to the Russound device",
- "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually."
- },
- "deprecated_yaml_import_issue_no_primary_controller": {
- "title": "The {integration_title} YAML configuration import cannot configure the Russound Device.",
- "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nNo primary controller was detected for the Russound device. Please make sure that the target Russound device has it's controller ID set to 1 (using the selector on the back of the unit)."
- },
- "deprecated_yaml_import_issue_unknown": {
- "title": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::title%]",
- "description": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::description%]"
+ "wrong_device": "This Russound controller does not match the existing device ID. Please make sure you entered the correct IP address."
}
},
"exceptions": {
diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json
index 27fbfbca57f..58925b4b1ff 100644
--- a/homeassistant/components/russound_rnet/manifest.json
+++ b/homeassistant/components/russound_rnet/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "russound_rnet",
"name": "Russound RNET",
- "codeowners": [],
+ "codeowners": ["@noahhusby"],
"documentation": "https://www.home-assistant.io/integrations/russound_rnet",
"iot_class": "local_polling",
"loggers": ["russound"],
diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py
index f8369ed64ca..48808930d9f 100644
--- a/homeassistant/components/russound_rnet/media_player.py
+++ b/homeassistant/components/russound_rnet/media_player.py
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/russound_rnet/quality_scale.yaml b/homeassistant/components/russound_rnet/quality_scale.yaml
new file mode 100644
index 00000000000..b82ef6f4643
--- /dev/null
+++ b/homeassistant/components/russound_rnet/quality_scale.yaml
@@ -0,0 +1,95 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: todo
+ brands: done
+ common-modules: todo
+ config-flow-test-coverage: todo
+ config-flow: todo
+ dependency-transparency:
+ status: todo
+ comment: |
+ CI pipeline for publishing is not on GH repo.
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup: todo
+ entity-unique-id: todo
+ has-entity-name: todo
+ runtime-data: todo
+ test-before-configure: todo
+ test-before-setup: todo
+ unique-config-entry: todo
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: todo
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: todo
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ The device does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ The device does not support discovery.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration is not a hub and only represents a single device.
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations:
+ status: exempt
+ comment: |
+ There are no entities to translate.
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: |
+ There are no entities that require icons.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed. An issue will be implemented for yaml import once a config flow is added.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration is not a hub and only represents a single device.
+
+ # Platinum
+ async-dependency: todo
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration uses telnet or serial exclusively and does not make http calls.
+ strict-typing: todo
diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py
index c22f100e87a..05ca93de9f2 100644
--- a/homeassistant/components/ruuvi_gateway/config_flow.py
+++ b/homeassistant/components/ruuvi_gateway/config_flow.py
@@ -8,11 +8,11 @@ from typing import Any
import aioruuvigateway.api as gw_api
from aioruuvigateway.excs import CannotConnect, InvalidAuth
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import DOMAIN
from .schemata import CONFIG_SCHEMA, get_config_schema_with_default_host
@@ -82,7 +82,7 @@ class RuuviConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a DHCP discovered Ruuvi Gateway."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py
index fee459340f3..1f68781a3a2 100644
--- a/homeassistant/components/sabnzbd/__init__.py
+++ b/homeassistant/components/sabnzbd/__init__.py
@@ -11,8 +11,7 @@ import voluptuous as vol
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
-from homeassistant.helpers import config_validation as cv
-import homeassistant.helpers.issue_registry as ir
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from .const import (
diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py
index c8b40fd5476..89b6658c418 100644
--- a/homeassistant/components/saj/sensor.py
+++ b/homeassistant/components/saj/sensor.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py
index 837651f9900..3f34520e87a 100644
--- a/homeassistant/components/samsungtv/config_flow.py
+++ b/homeassistant/components/samsungtv/config_flow.py
@@ -12,7 +12,6 @@ import getmac
from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator
import voluptuous as vol
-from homeassistant.components import dhcp, ssdp, zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
@@ -32,6 +31,14 @@ from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info
from .const import (
@@ -59,7 +66,7 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME):
def _strip_uuid(udn: str) -> str:
- return udn[5:] if udn.startswith("uuid:") else udn
+ return udn.removeprefix("uuid:")
def _entry_is_complete(
@@ -439,11 +446,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
raise AbortFlow(RESULT_NOT_SUPPORTED)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by ssdp discovery."""
LOGGER.debug("Samsung device found via SSDP: %s", discovery_info)
- model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or ""
+ model_name: str = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or ""
if discovery_info.ssdp_st == UPNP_SVC_RENDERING_CONTROL:
self._ssdp_rendering_control_location = discovery_info.ssdp_location
LOGGER.debug(
@@ -456,12 +463,10 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
"Set SSDP MainTvAgent location to: %s",
self._ssdp_main_tv_agent_location,
)
- self._udn = self._upnp_udn = _strip_uuid(
- discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
- )
+ self._udn = self._upnp_udn = _strip_uuid(discovery_info.upnp[ATTR_UPNP_UDN])
if hostname := urlparse(discovery_info.ssdp_location or "").hostname:
self._host = hostname
- self._manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER)
+ self._manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER)
self._abort_if_manufacturer_is_not_samsung()
# Set defaults, in case they cannot be extracted from device_info
@@ -486,7 +491,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by dhcp discovery."""
LOGGER.debug("Samsung device found via DHCP: %s", discovery_info)
@@ -498,7 +503,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info)
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index a1fda25589e..6a30efd64f8 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -35,11 +35,11 @@
"iot_class": "local_push",
"loggers": ["samsungctl", "samsungtvws"],
"requirements": [
- "getmac==0.9.4",
+ "getmac==0.9.5",
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==2.1.0",
- "async-upnp-client==0.42.0"
+ "async-upnp-client==0.43.0"
],
"ssdp": [
{
diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py
index 39c0d6b876d..41b2d0d561b 100644
--- a/homeassistant/components/satel_integra/alarm_control_panel.py
+++ b/homeassistant/components/satel_integra/alarm_control_panel.py
@@ -69,6 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
def __init__(self, controller, name, arm_home_mode, partition_id):
"""Initialize the alarm panel."""
self._attr_name = name
+ self._attr_unique_id = f"satel_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py
index 6ce82908de7..9135b58bc50 100644
--- a/homeassistant/components/satel_integra/switch.py
+++ b/homeassistant/components/satel_integra/switch.py
@@ -58,6 +58,7 @@ class SatelIntegraSwitch(SwitchEntity):
def __init__(self, controller, device_number, device_name, code):
"""Initialize the binary_sensor."""
self._device_number = device_number
+ self._attr_unique_id = f"satel_switch_{device_number}"
self._name = device_name
self._state = False
self._code = code
diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json
index 3fa750bf4ef..4c3e7ad43fe 100644
--- a/homeassistant/components/scene/strings.json
+++ b/homeassistant/components/scene/strings.json
@@ -38,12 +38,12 @@
"description": "The entity ID of the new scene."
},
"entities": {
- "name": "Entities state",
- "description": "List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead."
+ "name": "Entity states",
+ "description": "List of entities and their target state. If your entities are already in the target state right now, use 'Entities snapshot' instead."
},
"snapshot_entities": {
- "name": "Snapshot entities",
- "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`."
+ "name": "Entities snapshot",
+ "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine 'Entities snapshot' with 'Entity states'."
}
}
},
@@ -54,7 +54,7 @@
},
"exceptions": {
"entity_not_scene": {
- "message": "{entity_id} is not a valid scene entity_id."
+ "message": "{entity_id} is not a valid entity ID of a scene."
},
"entity_not_dynamically_created": {
"message": "The scene {entity_id} is not created with action `scene.create`."
diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py
index 24ce4f3b3fa..20dc9c1256a 100644
--- a/homeassistant/components/schedule/__init__.py
+++ b/homeassistant/components/schedule/__init__.py
@@ -19,6 +19,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.collection import (
CollectionEntity,
DictStorageCollection,
@@ -28,7 +29,6 @@ from homeassistant.helpers.collection import (
YamlCollection,
sync_entity_lifecycle,
)
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.service import async_register_admin_service
@@ -73,7 +73,7 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]:
)
# Check if the from time of the event is after the to time of the previous event
- if previous_to is not None and previous_to > time_range[CONF_FROM]: # type: ignore[unreachable]
+ if previous_to is not None and previous_to > time_range[CONF_FROM]:
raise vol.Invalid("Overlapping times found in schedule")
previous_to = time_range[CONF_TO]
diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py
index b319b21be0c..936ef9ee91e 100644
--- a/homeassistant/components/schlage/coordinator.py
+++ b/homeassistant/components/schlage/coordinator.py
@@ -13,7 +13,7 @@ from pyschlage.log import LockLog
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py
index 907841a2e5e..f7a8b631a05 100644
--- a/homeassistant/components/schluter/__init__.py
+++ b/homeassistant/components/schluter/__init__.py
@@ -9,8 +9,7 @@ import voluptuous as vol
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py
index ff991c5f348..68a8cf62fe4 100644
--- a/homeassistant/components/scrape/__init__.py
+++ b/homeassistant/components/scrape/__init__.py
@@ -19,8 +19,11 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery, entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ discovery,
+ entity_registry as er,
+)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py
index dd84767ad41..5ee837f32d1 100644
--- a/homeassistant/components/scrape/sensor.py
+++ b/homeassistant/components/scrape/sensor.py
@@ -158,6 +158,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti
self._index = index
self._value_template = value_template
self._attr_native_value = None
+ self._available = True
if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)):
self._attr_name = None
self._attr_has_entity_name = True
@@ -172,6 +173,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti
"""Parse the html extraction in the executor."""
raw_data = self.coordinator.data
value: str | list[str] | None
+ self._available = True
try:
if self._attr is not None:
value = raw_data.select(self._select)[self._index][self._attr]
@@ -184,11 +186,13 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti
except IndexError:
_LOGGER.warning("Index '%s' not found in %s", self._index, self.entity_id)
value = None
+ self._available = False
except KeyError:
_LOGGER.warning(
"Attribute '%s' not found in %s", self._attr, self.entity_id
)
value = None
+ self._available = False
_LOGGER.debug("Parsed value: %s", value)
return value
@@ -196,6 +200,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti
"""Ensure the data from the initial update is reflected in the state."""
await super().async_added_to_hass()
self._async_update_from_rest_data()
+ self.async_write_ha_state()
def _async_update_from_rest_data(self) -> None:
"""Update state from the rest data."""
@@ -210,21 +215,22 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti
SensorDeviceClass.TIMESTAMP,
}:
self._attr_native_value = value
+ self._attr_available = self._available
self._process_manual_data(raw_value)
return
self._attr_native_value = async_parse_date_datetime(
value, self.entity_id, self.device_class
)
+ self._attr_available = self._available
self._process_manual_data(raw_value)
- self.async_write_ha_state()
@property
def available(self) -> bool:
"""Return if entity is available."""
available1 = CoordinatorEntity.available.fget(self) # type: ignore[attr-defined]
available2 = ManualTriggerEntity.available.fget(self) # type: ignore[attr-defined]
- return bool(available1 and available2)
+ return bool(available1 and available2 and self._attr_available)
@callback
def _handle_coordinator_update(self) -> None:
diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py
index c0cff8d511b..e44d9b18ae1 100644
--- a/homeassistant/components/screenlogic/climate.py
+++ b/homeassistant/components/screenlogic/climate.py
@@ -93,7 +93,7 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
)
self._configured_heat_modes.append(HEAT_MODE.HEATER)
self._attr_preset_modes = [
- HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes
+ HEAT_MODE(mode_num).name.lower() for mode_num in self._configured_heat_modes
]
self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT]
@@ -137,8 +137,8 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
def preset_mode(self) -> str:
"""Return current/last preset mode."""
if self.hvac_mode == HVACMode.OFF:
- return HEAT_MODE(self._last_preset).title
- return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title
+ return HEAT_MODE(self._last_preset).name.lower()
+ return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).name.lower()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Change the setpoint of the heater."""
diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py
index 19db89dc03d..b4deb9b36aa 100644
--- a/homeassistant/components/screenlogic/config_flow.py
+++ b/homeassistant/components/screenlogic/config_flow.py
@@ -10,7 +10,6 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA
from screenlogicpy.requests import login
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -19,8 +18,9 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL
@@ -91,7 +91,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_gateway_select()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
mac = format_mac(discovery_info.macaddress)
@@ -105,7 +105,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_gateway_select(self, user_input=None) -> ConfigFlowResult:
"""Handle the selection of a discovered ScreenLogic gateway."""
- existing = self._async_current_ids()
+ existing = self._async_current_ids(include_ignore=False)
unconfigured_gateways = {
mac: gateway[SL_GATEWAY_NAME]
for mac, gateway in self.discovered_gateways.items()
diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py
index 6ae6e802859..7a5e910923c 100644
--- a/homeassistant/components/screenlogic/sensor.py
+++ b/homeassistant/components/screenlogic/sensor.py
@@ -9,7 +9,7 @@ from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE
from screenlogicpy.const.msg import CODE
from screenlogicpy.device_const.chemistry import DOSE_STATE
from screenlogicpy.device_const.pump import PUMP_TYPE
-from screenlogicpy.device_const.system import EQUIPMENT_FLAG
+from screenlogicpy.device_const.system import CONTROLLER_STATE, EQUIPMENT_FLAG
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
@@ -41,7 +41,7 @@ class ScreenLogicSensorDescription(
):
"""Describes a ScreenLogic sensor."""
- value_mod: Callable[[int | str], int | str] | None = None
+ value_mod: Callable[[int | str], int | str | None] | None = None
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -60,6 +60,18 @@ SUPPORTED_CORE_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
translation_key="air_temperature",
),
+ ScreenLogicPushSensorDescription(
+ subscription_code=CODE.STATUS_CHANGED,
+ data_root=(DEVICE.CONTROLLER, GROUP.SENSOR),
+ key=VALUE.STATE,
+ device_class=SensorDeviceClass.ENUM,
+ options=["ready", "sync", "service"],
+ value_mod=lambda val: (
+ CONTROLLER_STATE(val).name.lower() if val in [1, 2, 3] else None
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ translation_key="controller_state",
+ ),
]
SUPPORTED_PUMP_SENSORS = [
@@ -189,8 +201,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS),
key=VALUE.ORP_DOSING_STATE,
device_class=SensorDeviceClass.ENUM,
- options=["Dosing", "Mixing", "Monitoring"],
- value_mod=lambda val: DOSE_STATE(val).title,
+ options=["dosing", "mixing", "monitoring"],
+ value_mod=lambda val: DOSE_STATE(val).name.lower(),
translation_key="chem_dose_state",
translation_placeholders={"chem": "ORP"},
),
@@ -217,8 +229,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS),
key=VALUE.PH_DOSING_STATE,
device_class=SensorDeviceClass.ENUM,
- options=["Dosing", "Mixing", "Monitoring"],
- value_mod=lambda val: DOSE_STATE(val).title,
+ options=["dosing", "mixing", "monitoring"],
+ value_mod=lambda val: DOSE_STATE(val).name.lower(),
translation_key="chem_dose_state",
translation_placeholders={"chem": "pH"},
),
@@ -344,7 +356,7 @@ class ScreenLogicSensor(ScreenLogicEntity, SensorEntity):
)
@property
- def native_value(self) -> str | int | float:
+ def native_value(self) -> str | int | float | None:
"""State of the sensor."""
val = self.entity_data[ATTR.VALUE]
value_mod = self.entity_description.value_mod
diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json
index da5e3156592..09e64808dfe 100644
--- a/homeassistant/components/screenlogic/strings.json
+++ b/homeassistant/components/screenlogic/strings.json
@@ -3,8 +3,9 @@
"service_config_entry_name": "Config entry",
"service_config_entry_description": "The config entry to use for this action.",
"climate_preset_solar": "Solar",
- "climate_preset_solar_prefered": "Solar Prefered",
- "climate_preset_heater": "Heater"
+ "climate_preset_solar_preferred": "Solar Preferred",
+ "climate_preset_heater": "Heater",
+ "climate_preset_dont_change": "Don't Change"
},
"config": {
"flow_title": "{name}",
@@ -133,10 +134,30 @@
},
"climate": {
"body_0": {
- "name": "Pool heat"
+ "name": "Pool heat",
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "solar": "[%key:component::screenlogic::common::climate_preset_solar%]",
+ "solar_preferred": "[%key:component::screenlogic::common::climate_preset_solar_preferred%]",
+ "heater": "[%key:component::screenlogic::common::climate_preset_heater%]",
+ "dont_change": "[%key:component::screenlogic::common::climate_preset_dont_change%]"
+ }
+ }
+ }
},
"body_1": {
- "name": "Spa heat"
+ "name": "Spa heat",
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "solar": "[%key:component::screenlogic::common::climate_preset_solar%]",
+ "solar_preferred": "[%key:component::screenlogic::common::climate_preset_solar_preferred%]",
+ "heater": "[%key:component::screenlogic::common::climate_preset_heater%]",
+ "dont_change": "[%key:component::screenlogic::common::climate_preset_dont_change%]"
+ }
+ }
+ }
}
},
"number": {
@@ -163,6 +184,14 @@
"air_temperature": {
"name": "Air temperature"
},
+ "controller_state": {
+ "name": "Controller state",
+ "state": {
+ "ready": "Ready",
+ "sync": "Sync",
+ "service": "Service"
+ }
+ },
"chem_now": {
"name": "{chem} now"
},
@@ -191,7 +220,12 @@
"name": "[%key:component::screenlogic::entity::number::salt_tds_ppm::name%]"
},
"chem_dose_state": {
- "name": "{chem} dosing state"
+ "name": "{chem} dosing state",
+ "state": {
+ "dosing": "Dosing",
+ "mixing": "Mixing",
+ "monitoring": "Monitoring"
+ }
},
"chem_last_dose_time": {
"name": "{chem} last dose time"
diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py
index c0d79c446bb..dd293726484 100644
--- a/homeassistant/components/script/__init__.py
+++ b/homeassistant/components/script/__init__.py
@@ -8,7 +8,7 @@ from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, Any, cast
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -39,7 +39,7 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json
index d233b0680f8..d4fb227cf86 100644
--- a/homeassistant/components/script/strings.json
+++ b/homeassistant/components/script/strings.json
@@ -64,7 +64,7 @@
},
"toggle": {
"name": "[%key:common::action::toggle%]",
- "description": "Toggle a script. Starts it, if isn't running, stops it otherwise."
+ "description": "Starts a script if it isn't running, stops it otherwise."
}
}
}
diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py
index 9aabb315942..636c157b076 100644
--- a/homeassistant/components/scsgate/__init__.py
+++ b/homeassistant/components/scsgate/__init__.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.const import CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py
index b6d3317555c..4c4d2c2949a 100644
--- a/homeassistant/components/scsgate/cover.py
+++ b/homeassistant/components/scsgate/cover.py
@@ -18,7 +18,7 @@ from homeassistant.components.cover import (
)
from homeassistant.const import CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py
index 23b73a0fd6b..0addbda9e09 100644
--- a/homeassistant/components/scsgate/light.py
+++ b/homeassistant/components/scsgate/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py
index abc906a5533..4607d65ac7a 100644
--- a/homeassistant/components/scsgate/switch.py
+++ b/homeassistant/components/scsgate/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py
index 3834dc4a0c7..4196106edd2 100644
--- a/homeassistant/components/select/__init__.py
+++ b/homeassistant/components/select/__init__.py
@@ -6,7 +6,7 @@ from datetime import timedelta
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -45,15 +45,15 @@ __all__ = [
"ATTR_OPTION",
"ATTR_OPTIONS",
"DOMAIN",
- "PLATFORM_SCHEMA_BASE",
"PLATFORM_SCHEMA",
- "SelectEntity",
- "SelectEntityDescription",
+ "PLATFORM_SCHEMA_BASE",
"SERVICE_SELECT_FIRST",
"SERVICE_SELECT_LAST",
"SERVICE_SELECT_NEXT",
"SERVICE_SELECT_OPTION",
"SERVICE_SELECT_PREVIOUS",
+ "SelectEntity",
+ "SelectEntityDescription",
]
# mypy: disallow-any-generics
diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py
index a3827a23d41..1801d34d182 100644
--- a/homeassistant/components/select/device_action.py
+++ b/homeassistant/components/select/device_action.py
@@ -19,8 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_capability
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py
index 86f01804574..4dbb95085cb 100644
--- a/homeassistant/components/sendgrid/notify.py
+++ b/homeassistant/components/sendgrid/notify.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONTENT_TYPE_TEXT_PLAIN,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py
index 248be53ceb7..35c556a51f2 100644
--- a/homeassistant/components/sense/entity.py
+++ b/homeassistant/components/sense/entity.py
@@ -12,7 +12,7 @@ from .coordinator import SenseCoordinator
def sense_to_mdi(sense_icon: str) -> str:
"""Convert sense icon to mdi icon."""
- return f"mdi:{MDI_ICONS.get(sense_icon, "power-plug")}"
+ return f"mdi:{MDI_ICONS.get(sense_icon, 'power-plug')}"
class SenseEntity(CoordinatorEntity[SenseCoordinator]):
diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py
index 15ef3def1f5..06b5ea6588a 100644
--- a/homeassistant/components/sensibo/__init__.py
+++ b/homeassistant/components/sensibo/__init__.py
@@ -30,12 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> b
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool:
"""Unload Sensibo config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool:
"""Migrate old entry."""
# Change entry unique id from api_key to username
if entry.version == 1:
@@ -57,7 +57,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_config_entry_device(
- hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
+ hass: HomeAssistant, entry: SensiboConfigEntry, device: DeviceEntry
) -> bool:
"""Remove Sensibo config entry from a device."""
entity_registry = er.async_get(hass)
diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py
index 6d1acd99166..a66ab46c882 100644
--- a/homeassistant/components/sensibo/binary_sensor.py
+++ b/homeassistant/components/sensibo/binary_sensor.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+import logging
from typing import TYPE_CHECKING
from pysensibo.model import MotionSensor, SensiboDevice
@@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SensiboConfigEntry
+from .const import LOGGER
from .coordinator import SensiboDataUpdateCoordinator
from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity
@@ -26,14 +28,14 @@ PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class SensiboMotionBinarySensorEntityDescription(BinarySensorEntityDescription):
- """Describes Sensibo Motion sensor entity."""
+ """Describes Sensibo Motion binary sensor entity."""
value_fn: Callable[[MotionSensor], bool | None]
@dataclass(frozen=True, kw_only=True)
class SensiboDeviceBinarySensorEntityDescription(BinarySensorEntityDescription):
- """Describes Sensibo Motion sensor entity."""
+ """Describes Sensibo Motion binary sensor entity."""
value_fn: Callable[[SensiboDevice], bool | None]
@@ -122,32 +124,55 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
+ added_devices: set[str] = set()
- for device_id, device_data in coordinator.data.parsed.items():
- if device_data.motion_sensors:
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
+ nonlocal added_devices
+ new_devices, remove_devices, added_devices = coordinator.get_devices(
+ added_devices
+ )
+
+ if LOGGER.isEnabledFor(logging.DEBUG):
+ LOGGER.debug(
+ "New devices: %s, Removed devices: %s, Existing devices: %s",
+ new_devices,
+ remove_devices,
+ added_devices,
+ )
+
+ if new_devices:
entities.extend(
SensiboMotionSensor(
coordinator, device_id, sensor_id, sensor_data, description
)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_data.motion_sensors
for sensor_id, sensor_data in device_data.motion_sensors.items()
+ if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES
)
- entities.extend(
- SensiboDeviceSensor(coordinator, device_id, description)
- for description in MOTION_DEVICE_SENSOR_TYPES
- for device_id, device_data in coordinator.data.parsed.items()
- if device_data.motion_sensors
- )
- entities.extend(
- SensiboDeviceSensor(coordinator, device_id, description)
- for device_id, device_data in coordinator.data.parsed.items()
- for description in DESCRIPTION_BY_MODELS.get(
- device_data.model, DEVICE_SENSOR_TYPES
- )
- )
- async_add_entities(entities)
+ entities.extend(
+ SensiboDeviceSensor(coordinator, device_id, description)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_data.motion_sensors and device_id in new_devices
+ for description in MOTION_DEVICE_SENSOR_TYPES
+ )
+ entities.extend(
+ SensiboDeviceSensor(coordinator, device_id, description)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_id in new_devices
+ for description in DESCRIPTION_BY_MODELS.get(
+ device_data.model, DEVICE_SENSOR_TYPES
+ )
+ )
+
+ async_add_entities(entities)
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity):
diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py
index 9ac504537fa..df8d4625840 100644
--- a/homeassistant/components/sensibo/button.py
+++ b/homeassistant/components/sensibo/button.py
@@ -37,18 +37,30 @@ async def async_setup_entry(
entry: SensiboConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up Sensibo binary sensor platform."""
+ """Set up Sensibo button platform."""
coordinator = entry.runtime_data
- async_add_entities(
- SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
- for device_id, device_data in coordinator.data.parsed.items()
- )
+ added_devices: set[str] = set()
+
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ nonlocal added_devices
+ new_devices, _, added_devices = coordinator.get_devices(added_devices)
+
+ if new_devices:
+ async_add_entities(
+ SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
+ for device_id in coordinator.data.parsed
+ if device_id in new_devices
+ )
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity):
- """Representation of a Sensibo Device Binary Sensor."""
+ """Representation of a Sensibo Device button."""
entity_description: SensiboButtonEntityDescription
diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py
index 5bf455c3631..5d1c6ff9e79 100644
--- a/homeassistant/components/sensibo/climate.py
+++ b/homeassistant/components/sensibo/climate.py
@@ -1,14 +1,15 @@
-"""Support for Sensibo wifi-enabled home thermostats."""
+"""Support for Sensibo climate devices."""
from __future__ import annotations
from bisect import bisect_left
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_FAN_MODE,
+ ATTR_HVAC_MODE,
ATTR_SWING_MODE,
ClimateEntity,
ClimateEntityFeature,
@@ -21,8 +22,8 @@ from homeassistant.const import (
PRECISION_TENTHS,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.core import HomeAssistant, SupportsResponse
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -39,6 +40,7 @@ SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost"
SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost"
SERVICE_FULL_STATE = "full_state"
SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react"
+SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities"
ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold"
ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state"
ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold"
@@ -79,12 +81,28 @@ AVAILABLE_SWING_MODES = {
"horizontal",
"both",
}
+AVAILABLE_HORIZONTAL_SWING_MODES = {
+ "stopped",
+ "fixedleft",
+ "fixedcenterleft",
+ "fixedcenter",
+ "fixedcenterright",
+ "fixedright",
+ "fixedleftright",
+ "rangecenter",
+ "rangefull",
+ "rangeleft",
+ "rangeright",
+ "horizontal",
+ "both",
+}
PARALLEL_UPDATES = 0
FIELD_TO_FLAG = {
"fanLevel": ClimateEntityFeature.FAN_MODE,
"swing": ClimateEntityFeature.SWING_MODE,
+ "horizontalSwing": ClimateEntityFeature.SWING_HORIZONTAL_MODE,
"targetTemperature": ClimateEntityFeature.TARGET_TEMPERATURE,
}
@@ -105,6 +123,7 @@ AC_STATE_TO_DATA = {
"on": "device_on",
"mode": "hvac_mode",
"swing": "swing_mode",
+ "horizontalSwing": "horizontal_swing_mode",
}
@@ -125,12 +144,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- entities = [
- SensiboClimate(coordinator, device_id)
- for device_id, device_data in coordinator.data.parsed.items()
- ]
+ added_devices: set[str] = set()
- async_add_entities(entities)
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ nonlocal added_devices
+ new_devices, _, added_devices = coordinator.get_devices(added_devices)
+
+ if new_devices:
+ async_add_entities(
+ SensiboClimate(coordinator, device_id)
+ for device_id in coordinator.data.parsed
+ if device_id in new_devices
+ )
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -154,7 +183,7 @@ async def async_setup_entry(
vol.Required(ATTR_GEO_INTEGRATION): bool,
vol.Required(ATTR_INDOOR_INTEGRATION): bool,
vol.Required(ATTR_OUTDOOR_INTEGRATION): bool,
- vol.Required(ATTR_SENSITIVITY): vol.In(["Normal", "Sensitive"]),
+ vol.Required(ATTR_SENSITIVITY): vol.In(["normal", "sensitive"]),
},
"async_enable_pure_boost",
)
@@ -168,11 +197,10 @@ async def async_setup_entry(
vol.Optional(ATTR_FAN_MODE): str,
vol.Optional(ATTR_SWING_MODE): str,
vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str,
- vol.Optional(ATTR_LIGHT): vol.In(["on", "off"]),
+ vol.Optional(ATTR_LIGHT): vol.In(["on", "off", "dim"]),
},
"async_full_ac_state",
)
-
platform.async_register_entity_service(
SERVICE_ENABLE_CLIMATE_REACT,
{
@@ -181,15 +209,21 @@ async def async_setup_entry(
vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float),
vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict,
vol.Required(ATTR_SMART_TYPE): vol.In(
- ["temperature", "feelsLike", "humidity"]
+ ["temperature", "feelslike", "humidity"]
),
},
"async_enable_climate_react",
)
+ platform.async_register_entity_service(
+ SERVICE_GET_DEVICE_CAPABILITIES,
+ {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)},
+ "async_get_device_capabilities",
+ supports_response=SupportsResponse.ONLY,
+ )
class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
- """Representation of a Sensibo device."""
+ """Representation of a Sensibo climate device."""
_attr_name = None
_attr_precision = PRECISION_TENTHS
@@ -206,12 +240,12 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
if self.device_data.temp_unit == "C"
else UnitOfTemperature.FAHRENHEIT
)
- self._attr_supported_features = self.get_features()
- def get_features(self) -> ClimateEntityFeature:
- """Get supported features."""
+ @property
+ def supported_features(self) -> ClimateEntityFeature:
+ """Return the list of supported features."""
features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
- for key in self.device_data.full_features:
+ for key in self.device_data.active_features:
if key in FIELD_TO_FLAG:
features |= FIELD_TO_FLAG[key]
return features
@@ -231,8 +265,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available hvac operation modes."""
- if not self.device_data.hvac_modes:
- return [HVACMode.OFF]
+ if TYPE_CHECKING:
+ assert self.device_data.hvac_modes
return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes]
@property
@@ -285,6 +319,16 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
"""Return the list of available swing modes."""
return self.device_data.swing_modes
+ @property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the horizontal swing setting."""
+ return self.device_data.horizontal_swing_mode
+
+ @property
+ def swing_horizontal_modes(self) -> list[str] | None:
+ """Return the list of available horizontal swing modes."""
+ return self.device_data.horizontal_swing_modes
+
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
@@ -295,19 +339,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
"""Return the maximum temperature."""
return self.device_data.temp_list[-1]
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self.device_data.available and super().available
-
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- if "targetTemperature" not in self.device_data.active_features:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="no_target_temperature_in_features",
- )
-
temperature: float = kwargs[ATTR_TEMPERATURE]
if temperature == self.target_temperature:
return
@@ -322,11 +355,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
- if "fanLevel" not in self.device_data.active_features:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="no_fan_level_in_features",
- )
if fan_mode not in AVAILABLE_FAN_MODES:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -372,11 +400,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
- if "swing" not in self.device_data.active_features:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="no_swing_in_features",
- )
if swing_mode not in AVAILABLE_SWING_MODES:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -393,6 +416,26 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
transformation=transformation,
)
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new target horizontal swing operation."""
+ if swing_horizontal_mode not in AVAILABLE_HORIZONTAL_SWING_MODES:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="horizontal_swing_not_supported",
+ translation_placeholders={
+ "horizontal_swing_mode": swing_horizontal_mode
+ },
+ )
+
+ transformation = self.device_data.horizontal_swing_modes_translated
+ await self.async_send_api_call(
+ key=AC_STATE_TO_DATA["horizontalSwing"],
+ value=swing_horizontal_mode,
+ name="horizontalSwing",
+ assumed_state=False,
+ transformation=transformation,
+ )
+
async def async_turn_on(self) -> None:
"""Turn Sensibo unit on."""
await self.async_send_api_call(
@@ -411,6 +454,26 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
assumed_state=False,
)
+ async def async_get_device_capabilities(
+ self, hvac_mode: HVACMode
+ ) -> dict[str, Any]:
+ """Get capabilities from device."""
+ active_features = self.device_data.active_features
+ mode_capabilities: dict[str, Any] | None = self.device_data.full_capabilities[
+ "modes"
+ ].get(hvac_mode.value)
+ if not mode_capabilities:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="mode_not_exist"
+ )
+ remote_capabilities: dict[str, Any] = {}
+ for active_feature in active_features:
+ if active_feature in mode_capabilities:
+ remote_capabilities[active_feature.lower()] = mode_capabilities[
+ active_feature
+ ]
+ return remote_capabilities
+
async def async_assume_state(self, state: str) -> None:
"""Sync state with api."""
await self.async_send_api_call(
@@ -478,7 +541,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
"enabled": True,
}
if sensitivity is not None:
- params["sensitivity"] = sensitivity[0]
+ params["sensitivity"] = sensitivity[0].upper()
if indoor_integration is not None:
params["measurementsIntegration"] = indoor_integration
if ac_integration is not None:
@@ -518,6 +581,9 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
UnitOfTemperature.CELSIUS,
)
+ if smart_type == "feelslike":
+ smart_type = "feelsLike"
+
params: dict[str, str | bool | float | dict] = {
"enabled": True,
"deviceUid": self._device_id,
diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py
index b8b1029f141..e3d9f70d2c3 100644
--- a/homeassistant/components/sensibo/config_flow.py
+++ b/homeassistant/components/sensibo/config_flow.py
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.selector import TextSelector
-from .const import DEFAULT_NAME, DOMAIN
+from .const import DOMAIN
from .util import NoDevicesError, NoUsernameError, async_validate_api
DATA_SCHEMA = vol.Schema(
@@ -77,6 +77,9 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm",
data_schema=DATA_SCHEMA,
errors=errors,
+ description_placeholders={
+ "url": "https://www.home-assistant.io/integrations/sensibo/#prerequisites"
+ },
)
async def async_step_reconfigure(
@@ -103,6 +106,9 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reconfigure",
data_schema=DATA_SCHEMA,
errors=errors,
+ description_placeholders={
+ "url": "https://www.home-assistant.io/integrations/sensibo/#prerequisites"
+ },
)
async def async_step_user(
@@ -120,7 +126,7 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
- title=DEFAULT_NAME,
+ title=username,
data={CONF_API_KEY: api_key},
)
@@ -128,4 +134,7 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
+ description_placeholders={
+ "url": "https://www.home-assistant.io/integrations/sensibo/#prerequisites"
+ },
)
diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py
index cfd40195de3..e19f24295b9 100644
--- a/homeassistant/components/sensibo/coordinator.py
+++ b/homeassistant/components/sensibo/coordinator.py
@@ -12,6 +12,7 @@ from pysensibo.model import SensiboData
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -48,16 +49,61 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
session=async_get_clientsession(hass),
timeout=TIMEOUT,
)
+ self.previous_devices: set[str] = set()
+
+ def get_devices(
+ self, added_devices: set[str]
+ ) -> tuple[set[str], set[str], set[str]]:
+ """Addition and removal of devices."""
+ data = self.data
+ motion_sensors = {
+ sensor_id
+ for device_data in data.parsed.values()
+ if device_data.motion_sensors
+ for sensor_id in device_data.motion_sensors
+ }
+ devices: set[str] = set(data.parsed)
+ new_devices: set[str] = motion_sensors | devices - added_devices
+ remove_devices = added_devices - devices - motion_sensors
+ added_devices = (added_devices - remove_devices) | new_devices
+
+ return (new_devices, remove_devices, added_devices)
async def _async_update_data(self) -> SensiboData:
"""Fetch data from Sensibo."""
try:
data = await self.client.async_get_devices_data()
except AuthenticationError as error:
- raise ConfigEntryAuthFailed from error
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ ) from error
except SensiboError as error:
- raise UpdateFailed from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
if not data.raw:
- raise UpdateFailed("No devices found")
+ raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data")
+
+ current_devices = set(data.parsed)
+ for device_data in data.parsed.values():
+ if device_data.motion_sensors:
+ for motion_sensor_id in device_data.motion_sensors:
+ current_devices.add(motion_sensor_id)
+
+ if stale_devices := self.previous_devices - current_devices:
+ LOGGER.debug("Removing stale devices: %s", stale_devices)
+ device_registry = dr.async_get(self.hass)
+ for _id in stale_devices:
+ device = device_registry.async_get_device(identifiers={(DOMAIN, _id)})
+ if device:
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+ self.previous_devices = current_devices
+
return data
diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py
index b13a5f82111..f9ffc4b31c5 100644
--- a/homeassistant/components/sensibo/entity.py
+++ b/homeassistant/components/sensibo/entity.py
@@ -75,6 +75,11 @@ class SensiboBaseEntity(CoordinatorEntity[SensiboDataUpdateCoordinator]):
"""Return data for device."""
return self.coordinator.data.parsed[self._device_id]
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.device_data.available and super().available
+
class SensiboDeviceBaseEntity(SensiboBaseEntity):
"""Representation of a Sensibo Device."""
@@ -125,8 +130,13 @@ class SensiboMotionBaseEntity(SensiboBaseEntity):
)
@property
- def sensor_data(self) -> MotionSensor | None:
+ def sensor_data(self) -> MotionSensor:
"""Return data for Motion Sensor."""
if TYPE_CHECKING:
assert self.device_data.motion_sensors
return self.device_data.motion_sensors[self._sensor_id]
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return bool(self.sensor_data.alive) and super().available
diff --git a/homeassistant/components/sensibo/icons.json b/homeassistant/components/sensibo/icons.json
index ccab3c198d2..f97f0f1e80d 100644
--- a/homeassistant/components/sensibo/icons.json
+++ b/homeassistant/components/sensibo/icons.json
@@ -59,6 +59,9 @@
},
"enable_climate_react": {
"service": "mdi:wizard-hat"
+ },
+ "get_device_capabilities": {
+ "service": "mdi:shape-outline"
}
}
}
diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py
index baa056f0eea..aa46c7f8c1e 100644
--- a/homeassistant/components/sensibo/number.py
+++ b/homeassistant/components/sensibo/number.py
@@ -71,11 +71,23 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- SensiboNumber(coordinator, device_id, description)
- for device_id, device_data in coordinator.data.parsed.items()
- for description in DEVICE_NUMBER_TYPES
- )
+ added_devices: set[str] = set()
+
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ nonlocal added_devices
+ new_devices, _, added_devices = coordinator.get_devices(added_devices)
+
+ if new_devices:
+ async_add_entities(
+ SensiboNumber(coordinator, device_id, description)
+ for device_id in coordinator.data.parsed
+ for description in DEVICE_NUMBER_TYPES
+ if device_id in new_devices
+ )
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity):
diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml
new file mode 100644
index 00000000000..c21cf100e9d
--- /dev/null
+++ b/homeassistant/components/sensibo/quality_scale.yaml
@@ -0,0 +1,85 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry: done
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities doesn't subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: |
+ No integrations services.
+ common-modules: done
+ docs-high-level-description: todo
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ docs-actions: done
+ brands: done
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable:
+ status: done
+ comment: |
+ Move to base entity for common handling
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage:
+ status: done
+ comment: |
+ Tests are very complex and needs a rewrite for future additions
+ integration-owner: done
+ docs-installation-parameters:
+ status: todo
+ comment: configuration_basic
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration has no options flow.
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices: done
+ diagnostics:
+ status: done
+ comment: |
+ Change to only use redact once
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No local network connection, cloud based.
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ docs-use-cases: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-data-update: todo
+ docs-known-limitations: todo
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py
index cd0499aabc0..51521b59f03 100644
--- a/homeassistant/components/sensibo/select.py
+++ b/homeassistant/components/sensibo/select.py
@@ -8,10 +8,21 @@ from typing import TYPE_CHECKING, Any
from pysensibo.model import SensiboDevice
-from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.script import scripts_with_entity
+from homeassistant.components.select import (
+ DOMAIN as SELECT_DOMAIN,
+ SelectEntity,
+ SelectEntityDescription,
+)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+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,
+ async_delete_issue,
+)
from . import SensiboConfigEntry
from .const import DOMAIN
@@ -31,15 +42,17 @@ class SensiboSelectEntityDescription(SelectEntityDescription):
transformation: Callable[[SensiboDevice], dict | None]
+HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription(
+ key="horizontalSwing",
+ data_key="horizontal_swing_mode",
+ value_fn=lambda data: data.horizontal_swing_mode,
+ options_fn=lambda data: data.horizontal_swing_modes,
+ translation_key="horizontalswing",
+ transformation=lambda data: data.horizontal_swing_modes_translated,
+ entity_registry_enabled_default=False,
+)
+
DEVICE_SELECT_TYPES = (
- SensiboSelectEntityDescription(
- key="horizontalSwing",
- data_key="horizontal_swing_mode",
- value_fn=lambda data: data.horizontal_swing_mode,
- options_fn=lambda data: data.horizontal_swing_modes,
- translation_key="horizontalswing",
- transformation=lambda data: data.horizontal_swing_modes_translated,
- ),
SensiboSelectEntityDescription(
key="light",
data_key="light_mode",
@@ -56,16 +69,65 @@ async def async_setup_entry(
entry: SensiboConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up Sensibo number platform."""
+ """Set up Sensibo select platform."""
coordinator = entry.runtime_data
- async_add_entities(
- SensiboSelect(coordinator, device_id, description)
- for device_id, device_data in coordinator.data.parsed.items()
- for description in DEVICE_SELECT_TYPES
- if description.key in device_data.full_features
- )
+ entities: list[SensiboSelect] = []
+
+ entity_registry = er.async_get(hass)
+ for device_id, device_data in coordinator.data.parsed.items():
+ if entity_id := entity_registry.async_get_entity_id(
+ SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing"
+ ):
+ entity = entity_registry.async_get(entity_id)
+ if entity and entity.disabled:
+ entity_registry.async_remove(entity_id)
+ async_delete_issue(
+ hass,
+ DOMAIN,
+ "deprecated_entity_horizontalswing",
+ )
+ elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features:
+ entities.append(
+ SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE)
+ )
+ if automations_with_entity(hass, entity_id) or scripts_with_entity(
+ hass, entity_id
+ ):
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_entity_horizontalswing",
+ breaks_in_ha_version="2025.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_entity_horizontalswing",
+ translation_placeholders={
+ "name": str(entity.name or entity.original_name),
+ "entity": entity_id,
+ },
+ )
+ async_add_entities(entities)
+
+ added_devices: set[str] = set()
+
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ nonlocal added_devices
+ new_devices, _, added_devices = coordinator.get_devices(added_devices)
+
+ if new_devices:
+ async_add_entities(
+ SensiboSelect(coordinator, device_id, description)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_id in new_devices
+ for description in DEVICE_SELECT_TYPES
+ if description.key in device_data.full_features
+ )
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
@@ -84,6 +146,13 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}-{entity_description.key}"
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ if self.entity_description.key not in self.device_data.active_features:
+ return False
+ return super().available
+
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
@@ -99,17 +168,6 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Set state to the selected option."""
- if self.entity_description.key not in self.device_data.active_features:
- hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else ""
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="select_option_not_available",
- translation_placeholders={
- "hvac_mode": hvac_mode,
- "key": self.entity_description.key,
- },
- )
-
await self.async_send_api_call(
key=self.entity_description.data_key,
value=option,
diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py
index b395f8eb1ee..b242f38febe 100644
--- a/homeassistant/components/sensibo/sensor.py
+++ b/homeassistant/components/sensibo/sensor.py
@@ -36,6 +36,13 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity
PARALLEL_UPDATES = 0
+def _smart_type_name(_type: str | None) -> str | None:
+ """Return a lowercase name of smart type."""
+ if _type and _type == "feelsLike":
+ return "feelslike"
+ return _type
+
+
@dataclass(frozen=True, kw_only=True)
class SensiboMotionSensorEntityDescription(SensorEntityDescription):
"""Describes Sensibo Motion sensor entity."""
@@ -153,7 +160,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
SensiboDeviceSensorEntityDescription(
key="climate_react_type",
translation_key="smart_type",
- value_fn=lambda data: data.smart_type,
+ value_fn=lambda data: _smart_type_name(data.smart_type),
extra_fn=None,
entity_registry_enabled_default=False,
),
@@ -239,25 +246,40 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
+ added_devices: set[str] = set()
- for device_id, device_data in coordinator.data.parsed.items():
- if device_data.motion_sensors:
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+
+ entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
+ nonlocal added_devices
+ new_devices, remove_devices, added_devices = coordinator.get_devices(
+ added_devices
+ )
+
+ if new_devices:
entities.extend(
SensiboMotionSensor(
coordinator, device_id, sensor_id, sensor_data, description
)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_data.motion_sensors
for sensor_id, sensor_data in device_data.motion_sensors.items()
+ if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES
)
- entities.extend(
- SensiboDeviceSensor(coordinator, device_id, description)
- for device_id, device_data in coordinator.data.parsed.items()
- for description in DESCRIPTION_BY_MODELS.get(
- device_data.model, DEVICE_SENSOR_TYPES
- )
- )
- async_add_entities(entities)
+ entities.extend(
+ SensiboDeviceSensor(coordinator, device_id, description)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_id in new_devices
+ for description in DESCRIPTION_BY_MODELS.get(
+ device_data.model, DEVICE_SENSOR_TYPES
+ )
+ )
+ async_add_entities(entities)
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity):
diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml
index 7f8252af820..071f8c65609 100644
--- a/homeassistant/components/sensibo/services.yaml
+++ b/homeassistant/components/sensibo/services.yaml
@@ -12,6 +12,7 @@ assume_state:
options:
- "on"
- "off"
+ translation_key: assume_state
enable_timer:
target:
entity:
@@ -58,8 +59,9 @@ enable_pure_boost:
selector:
select:
options:
- - "Normal"
- - "Sensitive"
+ - "normal"
+ - "sensitive"
+ translation_key: sensitivity
full_state:
target:
entity:
@@ -78,6 +80,7 @@ full_state:
- "auto"
- "dry"
- "off"
+ translation_key: hvac_mode
target_temperature:
required: false
example: 23
@@ -113,6 +116,7 @@ full_state:
- "on"
- "off"
- "dim"
+ translation_key: light_mode
enable_climate_react:
target:
entity:
@@ -152,5 +156,24 @@ enable_climate_react:
select:
options:
- "temperature"
- - "feelsLike"
+ - "feelslike"
- "humidity"
+ translation_key: smart_type
+get_device_capabilities:
+ target:
+ entity:
+ integration: sensibo
+ domain: climate
+ fields:
+ hvac_mode:
+ required: true
+ example: "heat"
+ selector:
+ select:
+ options:
+ - "auto"
+ - "cool"
+ - "dry"
+ - "fan"
+ - "heat"
+ translation_key: hvac_mode
diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json
index 302e34bb5aa..c5ff0f135e6 100644
--- a/homeassistant/components/sensibo/strings.json
+++ b/homeassistant/components/sensibo/strings.json
@@ -8,9 +8,9 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "no_devices": "No devices discovered",
- "no_username": "Could not get username",
- "incorrect_api_key": "Invalid API key for selected account"
+ "no_devices": "No devices found, ensure your Sensibo devices are correctly set up and have a remote defined",
+ "no_username": "Could not retrieve username, ensure your Sensibo account has a proper username and try again",
+ "incorrect_api_key": "The provided API key does not match for this account"
},
"step": {
"user": {
@@ -18,7 +18,7 @@
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
- "api_key": "Follow the documentation to get your api key"
+ "api_key": "Follow the [documentation]({url}) to get your api key"
}
},
"reauth_confirm": {
@@ -387,6 +387,21 @@
"horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]",
"both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]"
}
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "stopped": "[%key:common::state::off%]",
+ "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]",
+ "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]",
+ "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]",
+ "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]",
+ "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]",
+ "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]",
+ "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]",
+ "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]",
+ "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]",
+ "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]"
+ }
}
}
}
@@ -470,8 +485,8 @@
}
},
"enable_climate_react": {
- "name": "Enable climate react",
- "description": "Enables and configures climate react.",
+ "name": "Enable Climate React",
+ "description": "Enables and configures Climate React.",
"fields": {
"high_temperature_threshold": {
"name": "Threshold high",
@@ -494,35 +509,92 @@
"description": "Choose between temperature/feels like/humidity."
}
}
+ },
+ "get_device_capabilities": {
+ "name": "Get device mode capabilities",
+ "description": "Retrieves the device capabilities for a specific device according to API requirements.",
+ "fields": {
+ "hvac_mode": {
+ "name": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::name%]",
+ "description": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::description%]"
+ }
+ }
+ }
+ },
+ "selector": {
+ "sensitivity": {
+ "options": {
+ "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]",
+ "sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]"
+ }
+ },
+ "assume_state": {
+ "options": {
+ "on": "[%key:common::state::on%]",
+ "off": "[%key:common::state::off%]"
+ }
+ },
+ "hvac_mode": {
+ "options": {
+ "cool": "[%key:component::climate::entity_component::_::state::cool%]",
+ "heat": "[%key:component::climate::entity_component::_::state::heat%]",
+ "fan": "[%key:component::climate::entity_component::_::state::fan_only%]",
+ "auto": "[%key:component::climate::entity_component::_::state::auto%]",
+ "dry": "[%key:component::climate::entity_component::_::state::dry%]",
+ "off": "[%key:common::state::off%]"
+ }
+ },
+ "light_mode": {
+ "options": {
+ "on": "[%key:common::state::on%]",
+ "off": "[%key:common::state::off%]",
+ "dim": "[%key:component::sensibo::entity::select::light::state::dim%]"
+ }
+ },
+ "smart_type": {
+ "options": {
+ "temperature": "[%key:component::sensor::entity_component::temperature::name%]",
+ "feelslike": "[%key:component::sensibo::entity::switch::climate_react_switch::state_attributes::type::state::feelslike%]",
+ "humidity": "[%key:component::sensor::entity_component::humidity::name%]"
+ }
}
},
"exceptions": {
- "no_target_temperature_in_features": {
- "message": "Current mode doesn't support setting target temperature"
- },
- "no_fan_level_in_features": {
- "message": "Current mode doesn't support setting fan level"
- },
"fan_mode_not_supported": {
"message": "Climate fan mode {fan_mode} is not supported by the integration, please open an issue"
},
- "no_swing_in_features": {
- "message": "Current mode doesn't support setting swing"
- },
"swing_not_supported": {
"message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue"
},
+ "horizontal_swing_not_supported": {
+ "message": "Climate horizontal swing mode {horizontal_swing_mode} is not supported by the integration, please open an issue"
+ },
"service_result_not_true": {
"message": "Could not perform action for {name}"
},
"service_raised": {
"message": "Could not perform action for {name} with error {error}"
},
- "select_option_not_available": {
- "message": "Current mode {hvac_mode} doesn't support setting {key}"
- },
"climate_react_not_available": {
- "message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app"
+ "message": "Use the Sensibo 'Enable Climate React' action once to enable the switch, or use the Sensibo app"
+ },
+ "auth_error": {
+ "message": "Authentication failed, please update your API key"
+ },
+ "update_error": {
+ "message": "There was an error updating from the Sensibo API with the error: {error}"
+ },
+ "no_data": {
+ "message": "[%key:component::sensibo::config::error::no_devices%]"
+ },
+ "mode_not_exist": {
+ "message": "The entity does not support the chosen mode"
+ }
+ },
+ "issues": {
+ "deprecated_entity_horizontalswing": {
+ "title": "The Sensibo {name} entity is deprecated",
+ "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue."
}
}
}
diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py
index 46906ac1871..0bc2c55a706 100644
--- a/homeassistant/components/sensibo/switch.py
+++ b/homeassistant/components/sensibo/switch.py
@@ -84,13 +84,25 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- SensiboDeviceSwitch(coordinator, device_id, description)
- for device_id, device_data in coordinator.data.parsed.items()
- for description in DESCRIPTION_BY_MODELS.get(
- device_data.model, DEVICE_SWITCH_TYPES
- )
- )
+ added_devices: set[str] = set()
+
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ nonlocal added_devices
+ new_devices, _, added_devices = coordinator.get_devices(added_devices)
+
+ if new_devices:
+ async_add_entities(
+ SensiboDeviceSwitch(coordinator, device_id, description)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_id in new_devices
+ for description in DESCRIPTION_BY_MODELS.get(
+ device_data.model, DEVICE_SWITCH_TYPES
+ )
+ )
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity):
diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py
index d52565564a6..0b02264b3e0 100644
--- a/homeassistant/components/sensibo/update.py
+++ b/homeassistant/components/sensibo/update.py
@@ -51,12 +51,24 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- SensiboDeviceUpdate(coordinator, device_id, description)
- for description in DEVICE_SENSOR_TYPES
- for device_id, device_data in coordinator.data.parsed.items()
- if description.value_available(device_data) is not None
- )
+ added_devices: set[str] = set()
+
+ def _add_remove_devices() -> None:
+ """Handle additions of devices and sensors."""
+ nonlocal added_devices
+ new_devices, _, added_devices = coordinator.get_devices(added_devices)
+
+ if new_devices:
+ async_add_entities(
+ SensiboDeviceUpdate(coordinator, device_id, description)
+ for device_id, device_data in coordinator.data.parsed.items()
+ if device_id in new_devices
+ for description in DEVICE_SENSOR_TYPES
+ if description.value_available(device_data) is not None
+ )
+
+ entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
+ _add_remove_devices()
class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity):
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 2933d779b4b..89f39d4fb8c 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -12,7 +12,7 @@ import logging
from math import ceil, floor, isfinite, log10
from typing import Any, Final, Self, cast, final, override
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
@@ -67,8 +67,8 @@ __all__ = [
"CONF_STATE_CLASS",
"DEVICE_CLASS_STATE_CLASSES",
"DOMAIN",
- "PLATFORM_SCHEMA_BASE",
"PLATFORM_SCHEMA",
+ "PLATFORM_SCHEMA_BASE",
"RestoreSensor",
"SensorDeviceClass",
"SensorEntity",
diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py
index 8c3c3925513..aaa14f4637c 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -392,7 +392,7 @@ class SensorDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
- Unit of measurement: `V`, `mV`, `µV`
+ Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV`
"""
VOLUME = "volume"
diff --git a/homeassistant/components/sensorpush/config_flow.py b/homeassistant/components/sensorpush/config_flow.py
index d826029276b..d3233ac2d5f 100644
--- a/homeassistant/components/sensorpush/config_flow.py
+++ b/homeassistant/components/sensorpush/config_flow.py
@@ -72,7 +72,7 @@ class SensorPushConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json
index cb5dc9ee100..cfe9196f596 100644
--- a/homeassistant/components/serial/manifest.json
+++ b/homeassistant/components/serial/manifest.json
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
- "requirements": ["pyserial-asyncio-fast==0.13"]
+ "requirements": ["pyserial-asyncio-fast==0.14"]
}
diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py
index a09401473b2..4d43408397f 100644
--- a/homeassistant/components/serial/sensor.py
+++ b/homeassistant/components/serial/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py
index b454424591d..570d1ac0d63 100644
--- a/homeassistant/components/serial_pm/sensor.py
+++ b/homeassistant/components/serial_pm/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py
index ad8b26f7034..5165d3d4798 100644
--- a/homeassistant/components/sesame/lock.py
+++ b/homeassistant/components/sesame/lock.py
@@ -13,7 +13,7 @@ from homeassistant.components.lock import (
)
from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py
index 63fd27e0dd0..bda17b75081 100644
--- a/homeassistant/components/seven_segments/image_processing.py
+++ b/homeassistant/components/seven_segments/image_processing.py
@@ -17,7 +17,7 @@ from homeassistant.components.image_processing import (
)
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
from homeassistant.core import HomeAssistant, split_entity_id
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json
index bf98140a4d6..cdc3b16f95d 100644
--- a/homeassistant/components/seven_segments/manifest.json
+++ b/homeassistant/components/seven_segments/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"iot_class": "local_polling",
"quality_scale": "legacy",
- "requirements": ["Pillow==11.0.0"]
+ "requirements": ["Pillow==11.1.0"]
}
diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py
index 6b888590600..19e2d3083c9 100644
--- a/homeassistant/components/seventeentrack/const.py
+++ b/homeassistant/components/seventeentrack/const.py
@@ -47,6 +47,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package"
ATTR_PACKAGE_STATE = "package_state"
ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
-
-
-DEPRECATED_KEY = "deprecated"
diff --git a/homeassistant/components/seventeentrack/repairs.py b/homeassistant/components/seventeentrack/repairs.py
deleted file mode 100644
index ce72960ea91..00000000000
--- a/homeassistant/components/seventeentrack/repairs.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""Repairs for the SeventeenTrack integration."""
-
-import voluptuous as vol
-
-from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import FlowResult
-
-from .const import DEPRECATED_KEY
-
-
-class SensorDeprecationRepairFlow(RepairsFlow):
- """Handler for an issue fixing flow."""
-
- def __init__(self, entry: ConfigEntry) -> None:
- """Create flow."""
- self.entry = entry
-
- async def async_step_init(
- self, user_input: dict[str, str] | None = None
- ) -> 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
- ) -> FlowResult:
- """Handle the confirm step of a fix flow."""
- if user_input is not None:
- data = {**self.entry.data, DEPRECATED_KEY: True}
- self.hass.config_entries.async_update_entry(self.entry, data=data)
- return self.async_create_entry(data={})
-
- return self.async_show_form(
- step_id="confirm",
- data_schema=vol.Schema({}),
- )
-
-
-async def async_create_fix_flow(
- hass: HomeAssistant, issue_id: str, data: dict
-) -> RepairsFlow:
- """Create flow."""
- if issue_id.startswith("deprecate_sensor_") and (
- entry := hass.config_entries.async_get_entry(data["entry_id"])
- ):
- return SensorDeprecationRepairFlow(entry)
- return ConfirmRepairFlow()
diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py
index 4e561a87961..dade9efb67c 100644
--- a/homeassistant/components/seventeentrack/sensor.py
+++ b/homeassistant/components/seventeentrack/sensor.py
@@ -4,12 +4,10 @@ from __future__ import annotations
from typing import Any
-from homeassistant.components import persistent_notification
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er, issue_registry as ir
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -17,23 +15,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SeventeenTrackCoordinator
from .const import (
- ATTR_DESTINATION_COUNTRY,
ATTR_INFO_TEXT,
- ATTR_ORIGIN_COUNTRY,
- ATTR_PACKAGE_TYPE,
ATTR_PACKAGES,
ATTR_STATUS,
ATTR_TIMESTAMP,
- ATTR_TRACKING_INFO_LANGUAGE,
ATTR_TRACKING_NUMBER,
ATTRIBUTION,
- DEPRECATED_KEY,
DOMAIN,
- LOGGER,
- NOTIFICATION_DELIVERED_MESSAGE,
- NOTIFICATION_DELIVERED_TITLE,
- UNIQUE_ID_TEMPLATE,
- VALUE_DELIVERED,
)
@@ -45,63 +33,12 @@ async def async_setup_entry(
"""Set up a 17Track sensor entry."""
coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- previous_tracking_numbers: set[str] = set()
-
- # This has been deprecated in 2024.8, will be removed in 2025.2
- @callback
- def _async_create_remove_entities():
- if config_entry.data.get(DEPRECATED_KEY):
- remove_packages(hass, coordinator.account_id, previous_tracking_numbers)
- return
- live_tracking_numbers = set(coordinator.data.live_packages.keys())
-
- new_tracking_numbers = live_tracking_numbers - previous_tracking_numbers
- old_tracking_numbers = previous_tracking_numbers - live_tracking_numbers
-
- previous_tracking_numbers.update(live_tracking_numbers)
-
- packages_to_add = [
- coordinator.data.live_packages[tracking_number]
- for tracking_number in new_tracking_numbers
- ]
-
- for package_data in coordinator.data.live_packages.values():
- if (
- package_data.status == VALUE_DELIVERED
- and not coordinator.show_delivered
- ):
- old_tracking_numbers.add(package_data.tracking_number)
- notify_delivered(
- hass,
- package_data.friendly_name,
- package_data.tracking_number,
- )
-
- remove_packages(hass, coordinator.account_id, old_tracking_numbers)
-
- async_add_entities(
- SeventeenTrackPackageSensor(
- coordinator,
- package_data.tracking_number,
- )
- for package_data in packages_to_add
- if not (
- not coordinator.show_delivered and package_data.status == "Delivered"
- )
- )
async_add_entities(
SeventeenTrackSummarySensor(status, coordinator)
for status, summary_data in coordinator.data.summary.items()
)
- if not config_entry.data.get(DEPRECATED_KEY):
- deprecate_sensor_issue(hass, config_entry.entry_id)
- _async_create_remove_entities()
- config_entry.async_on_unload(
- coordinator.async_add_listener(_async_create_remove_entities)
- )
-
class SeventeenTrackSensor(CoordinatorEntity[SeventeenTrackCoordinator], SensorEntity):
"""Define a 17Track sensor."""
@@ -163,96 +100,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor):
for package in packages
]
}
-
-
-# The dynamic package sensors have been replaced by the seventeentrack.get_packages service
-class SeventeenTrackPackageSensor(SeventeenTrackSensor):
- """Define an individual package sensor."""
-
- _attr_translation_key = "package"
-
- def __init__(
- self,
- coordinator: SeventeenTrackCoordinator,
- tracking_number: str,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator)
- self._tracking_number = tracking_number
- self._previous_status = coordinator.data.live_packages[tracking_number].status
- self._attr_unique_id = UNIQUE_ID_TEMPLATE.format(
- coordinator.account_id, tracking_number
- )
- package = coordinator.data.live_packages[tracking_number]
- if not (name := package.friendly_name):
- name = tracking_number
- self._attr_translation_placeholders = {"name": name}
-
- @property
- def available(self) -> bool:
- """Return whether the entity is available."""
- return self._tracking_number in self.coordinator.data.live_packages
-
- @property
- def native_value(self) -> StateType:
- """Return the state."""
- return self.coordinator.data.live_packages[self._tracking_number].status
-
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- package = self.coordinator.data.live_packages[self._tracking_number]
- return {
- ATTR_DESTINATION_COUNTRY: package.destination_country,
- ATTR_INFO_TEXT: package.info_text,
- ATTR_TIMESTAMP: package.timestamp,
- ATTR_LOCATION: package.location,
- ATTR_ORIGIN_COUNTRY: package.origin_country,
- ATTR_PACKAGE_TYPE: package.package_type,
- ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language,
- ATTR_TRACKING_NUMBER: package.tracking_number,
- }
-
-
-def remove_packages(hass: HomeAssistant, account_id: str, packages: set[str]) -> None:
- """Remove entity itself."""
- reg = er.async_get(hass)
- for package in packages:
- entity_id = reg.async_get_entity_id(
- "sensor",
- "seventeentrack",
- UNIQUE_ID_TEMPLATE.format(account_id, package),
- )
- if entity_id:
- reg.async_remove(entity_id)
-
-
-def notify_delivered(hass: HomeAssistant, friendly_name: str, tracking_number: str):
- """Notify when package is delivered."""
- LOGGER.debug("Package delivered: %s", tracking_number)
-
- identification = friendly_name if friendly_name else tracking_number
- message = NOTIFICATION_DELIVERED_MESSAGE.format(identification, tracking_number)
- title = NOTIFICATION_DELIVERED_TITLE.format(identification)
- notification_id = NOTIFICATION_DELIVERED_TITLE.format(tracking_number)
-
- persistent_notification.create(
- hass, message, title=title, notification_id=notification_id
- )
-
-
-@callback
-def deprecate_sensor_issue(hass: HomeAssistant, entry_id: str) -> None:
- """Ensure an issue is registered."""
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"deprecate_sensor_{entry_id}",
- breaks_in_ha_version="2025.2.0",
- issue_domain=DOMAIN,
- is_fixable=True,
- is_persistent=True,
- translation_key="deprecate_sensor",
- severity=ir.IssueSeverity.WARNING,
- data={"entry_id": entry_id},
- )
diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json
index bbd01ed3055..982b15ab629 100644
--- a/homeassistant/components/seventeentrack/strings.json
+++ b/homeassistant/components/seventeentrack/strings.json
@@ -37,19 +37,6 @@
}
}
},
- "issues": {
- "deprecate_sensor": {
- "title": "17Track package sensors are being deprecated",
- "fix_flow": {
- "step": {
- "confirm": {
- "title": "[%key:component::seventeentrack::issues::deprecate_sensor::title%]",
- "description": "17Track package sensors are deprecated and will be removed.\nPlease update your automations and scripts to get data using the `seventeentrack.get_packages` action."
- }
- }
- }
- }
- },
"entity": {
"sensor": {
"not_found": {
diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py
index 873d3fbd290..332d95b0a3e 100644
--- a/homeassistant/components/sharkiq/vacuum.py
+++ b/homeassistant/components/sharkiq/vacuum.py
@@ -16,8 +16,7 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py
index e0d9d17d55d..5ca58ec7d01 100644
--- a/homeassistant/components/shelly/__init__.py
+++ b/homeassistant/components/shelly/__init__.py
@@ -15,6 +15,7 @@ from aioshelly.exceptions import (
from aioshelly.rpc_device import RpcDevice
import voluptuous as vol
+from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -331,3 +332,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b
return await hass.config_entries.async_unload_platforms(
entry, runtime_data.platforms
)
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> None:
+ """Remove a config entry."""
+ if get_device_entry_gen(entry) in RPC_GENERATIONS and (
+ mac_address := entry.unique_id
+ ):
+ async_remove_scanner(hass, mac_address)
diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py
index 556274aa51a..108a8236733 100644
--- a/homeassistant/components/shelly/binary_sensor.py
+++ b/homeassistant/components/shelly/binary_sensor.py
@@ -15,11 +15,12 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_SLEEP_PERIOD
-from .coordinator import ShellyConfigEntry
+from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
RestEntityDescription,
@@ -59,6 +60,36 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr
"""Class to describe a REST binary sensor."""
+class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
+ """Represent a RPC binary sensor entity."""
+
+ entity_description: RpcBinarySensorDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if RPC sensor state is on."""
+ return bool(self.attribute_value)
+
+
+class RpcBluTrvBinarySensor(RpcBinarySensor):
+ """Represent a RPC BluTrv binary sensor."""
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcBinarySensorDescription,
+ ) -> None:
+ """Initialize."""
+
+ super().__init__(coordinator, key, attribute, description)
+ ble_addr: str = coordinator.device.config[key]["addr"]
+ self._attr_device_info = DeviceInfo(
+ connections={(CONNECTION_BLUETOOTH, ble_addr)}
+ )
+
+
SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
("device", "overtemp"): BlockBinarySensorDescription(
key="device|overtemp",
@@ -232,6 +263,15 @@ RPC_SENSORS: Final = {
sub_key="value",
has_entity_name=True,
),
+ "calibration": RpcBinarySensorDescription(
+ key="blutrv",
+ sub_key="errors",
+ name="Calibration",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ value=lambda status, _: False if status is None else "not_calibrated" in status,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_class=RpcBluTrvBinarySensor,
+ ),
}
@@ -320,17 +360,6 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
return bool(self.attribute_value)
-class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
- """Represent a RPC binary sensor entity."""
-
- entity_description: RpcBinarySensorDescription
-
- @property
- def is_on(self) -> bool:
- """Return true if RPC sensor state is on."""
- return bool(self.attribute_value)
-
-
class BlockSleepingBinarySensor(
ShellySleepingBlockAttributeEntity, BinarySensorEntity, RestoreEntity
):
diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py
index f2b71d19d61..366d5c51d25 100644
--- a/homeassistant/components/shelly/bluetooth/__init__.py
+++ b/homeassistant/components/shelly/bluetooth/__init__.py
@@ -21,6 +21,7 @@ async def async_connect_scanner(
hass: HomeAssistant,
coordinator: ShellyRpcCoordinator,
scanner_mode: BLEScannerMode,
+ device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
device = coordinator.device
@@ -28,7 +29,14 @@ async def async_connect_scanner(
source = format_mac(coordinator.mac).upper()
scanner = create_scanner(source, entry.title)
unload_callbacks = [
- async_register_scanner(hass, scanner),
+ async_register_scanner(
+ hass,
+ scanner,
+ source_domain=entry.domain,
+ source_model=coordinator.model,
+ source_config_entry_id=entry.entry_id,
+ source_device_id=device_id,
+ ),
scanner.async_setup(),
coordinator.async_subscribe_events(scanner.async_on_event),
]
diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py
index 842abc5ecc4..f1491acdd81 100644
--- a/homeassistant/components/shelly/climate.py
+++ b/homeassistant/components/shelly/climate.py
@@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass
from typing import Any, cast
from aioshelly.block_device import Block
-from aioshelly.const import RPC_GENERATIONS
+from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.climate import (
@@ -22,7 +22,11 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ CONNECTION_NETWORK_MAC,
+ DeviceInfo,
+)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
@@ -31,6 +35,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .const import (
+ BLU_TRV_TEMPERATURE_SETTINGS,
+ BLU_TRV_TIMEOUT,
DOMAIN,
LOGGER,
NOT_CALIBRATED_ISSUE_ID,
@@ -124,6 +130,7 @@ def async_setup_rpc_entry(
coordinator = config_entry.runtime_data.rpc
assert coordinator
climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat")
+ blutrv_key_ids = get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER)
climate_ids = []
for id_ in climate_key_ids:
@@ -139,10 +146,11 @@ def async_setup_rpc_entry(
unique_id = f"{coordinator.mac}-switch:{id_}"
async_remove_shelly_entity(hass, "switch", unique_id)
- if not climate_ids:
- return
+ if climate_ids:
+ async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids)
- async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids)
+ if blutrv_key_ids:
+ async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids)
@dataclass
@@ -526,3 +534,76 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity):
await self.call_rpc(
"Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}}
)
+
+
+class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity):
+ """Entity that controls a thermostat on RPC based Shelly devices."""
+
+ _attr_max_temp = BLU_TRV_TEMPERATURE_SETTINGS["max"]
+ _attr_min_temp = BLU_TRV_TEMPERATURE_SETTINGS["min"]
+ _attr_supported_features = (
+ ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON
+ )
+ _attr_hvac_modes = [HVACMode.HEAT]
+ _attr_hvac_mode = HVACMode.HEAT
+ _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"]
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
+ """Initialize."""
+
+ super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}")
+ self._id = id_
+ self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]
+ ble_addr: str = self._config["addr"]
+ self._attr_unique_id = f"{ble_addr}-{self.key}"
+ name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}"
+ model_id = self._config.get("local_name")
+ self._attr_device_info = DeviceInfo(
+ connections={(CONNECTION_BLUETOOTH, ble_addr)},
+ identifiers={(DOMAIN, ble_addr)},
+ via_device=(DOMAIN, self.coordinator.mac),
+ manufacturer="Shelly",
+ model=BLU_TRV_MODEL_NAME.get(model_id),
+ model_id=model_id,
+ name=name,
+ )
+ # Added intentionally to the constructor to avoid double name from base class
+ self._attr_name = None
+
+ @property
+ def target_temperature(self) -> float | None:
+ """Set target temperature."""
+ if not self._config["enable"]:
+ return None
+
+ return cast(float, self.status["target_C"])
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return current temperature."""
+ return cast(float, self.status["current_C"])
+
+ @property
+ def hvac_action(self) -> HVACAction:
+ """HVAC current action."""
+ if not self.status["pos"]:
+ return HVACAction.IDLE
+
+ return HVACAction.HEATING
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None:
+ return
+
+ await self.call_rpc(
+ "BluTRV.Call",
+ {
+ "id": self._id,
+ "method": "Trv.SetTarget",
+ "params": {"id": 0, "target_C": target_temp},
+ },
+ timeout=BLU_TRV_TIMEOUT,
+ )
diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py
index 55686464637..f53da8bd766 100644
--- a/homeassistant/components/shelly/config_flow.py
+++ b/homeassistant/components/shelly/config_flow.py
@@ -17,7 +17,6 @@ from aioshelly.exceptions import (
from aioshelly.rpc_device import RpcDevice
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -34,6 +33,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_BLE_SCANNER_MODE,
diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py
index 88d8c1f5f17..e78a6f1a59d 100644
--- a/homeassistant/components/shelly/const.py
+++ b/homeassistant/components/shelly/const.py
@@ -187,6 +187,13 @@ RPC_THERMOSTAT_SETTINGS: Final = {
"step": 0.5,
}
+BLU_TRV_TEMPERATURE_SETTINGS: Final = {
+ "min": 4,
+ "max": 30,
+ "step": 0.1,
+ "default": 20.0,
+}
+
# Kelvin value for colorTemp
KELVIN_MAX_VALUE: Final = 6500
KELVIN_MIN_VALUE_WHITE: Final = 2700
@@ -230,6 +237,7 @@ OTA_SUCCESS = "ota_success"
GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog"
GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/"
+GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased"
DEVICES_WITHOUT_FIRMWARE_CHANGELOG = (
MODEL_WALL_DISPLAY,
MODEL_MOTION,
@@ -257,3 +265,6 @@ VIRTUAL_NUMBER_MODE_MAP = {
API_WS_URL = "/api/shelly/ws"
COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+")
+
+# value confirmed by Shelly team
+BLU_TRV_TIMEOUT = 60
diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py
index f20b283cacf..f2a01240f70 100644
--- a/homeassistant/components/shelly/coordinator.py
+++ b/homeassistant/components/shelly/coordinator.py
@@ -18,8 +18,9 @@ from aioshelly.exceptions import (
RpcCallError,
)
from aioshelly.rpc_device import RpcDevice, RpcUpdateType
-from propcache import cached_property
+from propcache.api import cached_property
+from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
ATTR_DEVICE_ID,
@@ -30,7 +31,7 @@ from homeassistant.const import (
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.debounce import Debouncer
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .bluetooth import async_connect_scanner
@@ -154,6 +155,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
config_entry_id=self.entry.entry_id,
name=self.name,
connections={(CONNECTION_NETWORK_MAC, self.mac)},
+ identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly",
model=MODEL_NAMES.get(self.model),
model_id=self.model,
@@ -371,7 +373,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]):
try:
await self.device.update()
except DeviceConnectionError as err:
- raise UpdateFailed(f"Error fetching data: {err!r}") from err
+ raise UpdateFailed(repr(err)) from err
except InvalidAuthError:
await self.async_shutdown_device_and_start_reauth()
@@ -456,7 +458,7 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]):
return
await self.device.update_shelly()
except (DeviceConnectionError, MacAddressMismatchError) as err:
- raise UpdateFailed(f"Error fetching data: {err!r}") from err
+ raise UpdateFailed(repr(err)) from err
except InvalidAuthError:
await self.async_shutdown_device_and_start_reauth()
else:
@@ -696,13 +698,17 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
)
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
+ async_remove_scanner(self.hass, format_mac(self.mac).upper())
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
return
+ assert self.device_id is not None
self._disconnected_callbacks.append(
- await async_connect_scanner(self.hass, self, ble_scanner_mode)
+ await async_connect_scanner(
+ self.hass, self, ble_scanner_mode, self.device_id
+ )
)
@callback
diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py
index aea060e09e2..001727c74b3 100644
--- a/homeassistant/components/shelly/entity.py
+++ b/homeassistant/components/shelly/entity.py
@@ -196,10 +196,16 @@ def async_setup_rpc_attribute_entities(
elif description.use_polling_coordinator:
if not sleep_period:
entities.append(
- sensor_class(polling_coordinator, key, sensor_id, description)
+ get_entity_class(sensor_class, description)(
+ polling_coordinator, key, sensor_id, description
+ )
)
else:
- entities.append(sensor_class(coordinator, key, sensor_id, description))
+ entities.append(
+ get_entity_class(sensor_class, description)(
+ coordinator, key, sensor_id, description
+ )
+ )
if not entities:
return
@@ -232,7 +238,9 @@ def async_restore_rpc_attribute_entities(
if description := sensors.get(attribute):
entities.append(
- sensor_class(coordinator, key, attribute, description, entry)
+ get_entity_class(sensor_class, description)(
+ coordinator, key, attribute, description, entry
+ )
)
if not entities:
@@ -293,6 +301,7 @@ class RpcEntityDescription(EntityDescription):
supported: Callable = lambda _: False
unit: Callable[[dict], str | None] | None = None
options_fn: Callable[[dict], list[str]] | None = None
+ entity_class: Callable | None = None
@dataclass(frozen=True)
@@ -381,15 +390,20 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
"""Handle device update."""
self.async_write_ha_state()
- async def call_rpc(self, method: str, params: Any) -> Any:
+ async def call_rpc(
+ self, method: str, params: Any, timeout: float | None = None
+ ) -> Any:
"""Call RPC method."""
LOGGER.debug(
- "Call RPC for entity %s, method: %s, params: %s",
+ "Call RPC for entity %s, method: %s, params: %s, timeout: %s",
self.name,
method,
params,
+ timeout,
)
try:
+ if timeout:
+ return await self.coordinator.device.call_rpc(method, params, timeout)
return await self.coordinator.device.call_rpc(method, params)
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
@@ -673,3 +687,13 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity):
"Entity %s comes from a sleeping device, update is not possible",
self.entity_id,
)
+
+
+def get_entity_class(
+ sensor_class: Callable, description: RpcEntityDescription
+) -> Callable:
+ """Return entity class."""
+ if description.entity_class is not None:
+ return description.entity_class
+
+ return sensor_class
diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json
index 1baf61acf3b..f93abf6b854 100644
--- a/homeassistant/components/shelly/icons.json
+++ b/homeassistant/components/shelly/icons.json
@@ -12,6 +12,9 @@
}
},
"number": {
+ "external_temperature": {
+ "default": "mdi:thermometer-check"
+ },
"valve_position": {
"default": "mdi:pipe-valve"
}
@@ -29,6 +32,9 @@
"tilt": {
"default": "mdi:angle-acute"
},
+ "valve_position": {
+ "default": "mdi:pipe-valve"
+ },
"valve_status": {
"default": "mdi:valve"
}
diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py
index fbf72e6ebe8..e18cd7ca465 100644
--- a/homeassistant/components/shelly/logbook.py
+++ b/homeassistant/components/shelly/logbook.py
@@ -42,7 +42,7 @@ def async_describe_events(
if click_type in RPC_INPUTS_EVENTS_TYPES:
rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id)
if rpc_coordinator and rpc_coordinator.device.initialized:
- key = f"input:{channel-1}"
+ key = f"input:{channel - 1}"
input_name = get_rpc_entity_name(rpc_coordinator.device, key)
elif click_type in BLOCK_INPUTS_EVENTS_TYPES:
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 29c8fd4c369..e0d8c03ffc4 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
- "requirements": ["aioshelly==12.2.0"],
+ "requirements": ["aioshelly==12.3.2"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py
index 2aed38fb723..c4420783bbb 100644
--- a/homeassistant/components/shelly/number.py
+++ b/homeassistant/components/shelly/number.py
@@ -18,13 +18,14 @@ from homeassistant.components.number import (
NumberMode,
RestoreNumber,
)
-from homeassistant.const import PERCENTAGE, EntityCategory
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
-from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP
+from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -57,6 +58,85 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription):
min_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None
mode_fn: Callable[[dict], NumberMode] | None = None
+ method: str
+ method_params_fn: Callable[[int, float], dict]
+
+
+class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
+ """Represent a RPC number entity."""
+
+ entity_description: RpcNumberDescription
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcNumberDescription,
+ ) -> None:
+ """Initialize sensor."""
+ super().__init__(coordinator, key, attribute, description)
+
+ if description.max_fn is not None:
+ self._attr_native_max_value = description.max_fn(
+ coordinator.device.config[key]
+ )
+ if description.min_fn is not None:
+ self._attr_native_min_value = description.min_fn(
+ coordinator.device.config[key]
+ )
+ if description.step_fn is not None:
+ self._attr_native_step = description.step_fn(coordinator.device.config[key])
+ if description.mode_fn is not None:
+ self._attr_mode = description.mode_fn(coordinator.device.config[key])
+
+ @property
+ def native_value(self) -> float | None:
+ """Return value of number."""
+ if TYPE_CHECKING:
+ assert isinstance(self.attribute_value, float | None)
+
+ return self.attribute_value
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Change the value."""
+ if TYPE_CHECKING:
+ assert isinstance(self._id, int)
+
+ await self.call_rpc(
+ self.entity_description.method,
+ self.entity_description.method_params_fn(self._id, value),
+ )
+
+
+class RpcBluTrvNumber(RpcNumber):
+ """Represent a RPC BluTrv number."""
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcNumberDescription,
+ ) -> None:
+ """Initialize."""
+
+ super().__init__(coordinator, key, attribute, description)
+ ble_addr: str = coordinator.device.config[key]["addr"]
+ self._attr_device_info = DeviceInfo(
+ connections={(CONNECTION_BLUETOOTH, ble_addr)}
+ )
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Change the value."""
+ if TYPE_CHECKING:
+ assert isinstance(self._id, int)
+
+ await self.call_rpc(
+ self.entity_description.method,
+ self.entity_description.method_params_fn(self._id, value),
+ timeout=BLU_TRV_TIMEOUT,
+ )
NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
@@ -78,6 +158,25 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
RPC_NUMBERS: Final = {
+ "external_temperature": RpcNumberDescription(
+ key="blutrv",
+ sub_key="current_C",
+ translation_key="external_temperature",
+ name="External temperature",
+ native_min_value=-50,
+ native_max_value=50,
+ native_step=0.1,
+ mode=NumberMode.BOX,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ method="BluTRV.Call",
+ method_params_fn=lambda idx, value: {
+ "id": idx,
+ "method": "Trv.SetExternalTemperature",
+ "params": {"id": 0, "t_C": value},
+ },
+ entity_class=RpcBluTrvNumber,
+ ),
"number": RpcNumberDescription(
key="number",
sub_key="value",
@@ -87,11 +186,33 @@ RPC_NUMBERS: Final = {
mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get(
config["meta"]["ui"]["view"], NumberMode.BOX
),
- step_fn=lambda config: config["meta"]["ui"]["step"],
+ step_fn=lambda config: config["meta"]["ui"].get("step"),
# If the unit is not set, the device sends an empty string
unit=lambda config: config["meta"]["ui"]["unit"]
if config["meta"]["ui"]["unit"]
else None,
+ method="Number.Set",
+ method_params_fn=lambda idx, value: {"id": idx, "value": value},
+ ),
+ "valve_position": RpcNumberDescription(
+ key="blutrv",
+ sub_key="pos",
+ translation_key="valve_position",
+ name="Valve position",
+ native_min_value=0,
+ native_max_value=100,
+ native_step=1,
+ mode=NumberMode.SLIDER,
+ native_unit_of_measurement=PERCENTAGE,
+ method="BluTRV.Call",
+ method_params_fn=lambda idx, value: {
+ "id": idx,
+ "method": "Trv.SetPosition",
+ "params": {"id": 0, "pos": int(value)},
+ },
+ removal_condition=lambda config, _status, key: config[key].get("enable", True)
+ is True,
+ entity_class=RpcBluTrvNumber,
),
}
@@ -190,44 +311,3 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
-
-
-class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
- """Represent a RPC number entity."""
-
- entity_description: RpcNumberDescription
-
- def __init__(
- self,
- coordinator: ShellyRpcCoordinator,
- key: str,
- attribute: str,
- description: RpcNumberDescription,
- ) -> None:
- """Initialize sensor."""
- super().__init__(coordinator, key, attribute, description)
-
- if description.max_fn is not None:
- self._attr_native_max_value = description.max_fn(
- coordinator.device.config[key]
- )
- if description.min_fn is not None:
- self._attr_native_min_value = description.min_fn(
- coordinator.device.config[key]
- )
- if description.step_fn is not None:
- self._attr_native_step = description.step_fn(coordinator.device.config[key])
- if description.mode_fn is not None:
- self._attr_mode = description.mode_fn(coordinator.device.config[key])
-
- @property
- def native_value(self) -> float | None:
- """Return value of number."""
- if TYPE_CHECKING:
- assert isinstance(self.attribute_value, float | None)
-
- return self.attribute_value
-
- async def async_set_native_value(self, value: float) -> None:
- """Change the value."""
- await self.call_rpc("Number.Set", {"id": self._id, "value": value})
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
index dd0ace9a6b9..6d000556cf3 100644
--- a/homeassistant/components/shelly/sensor.py
+++ b/homeassistant/components/shelly/sensor.py
@@ -33,6 +33,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
@@ -76,6 +77,57 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription):
"""Class to describe a REST sensor."""
+class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
+ """Represent a RPC sensor."""
+
+ entity_description: RpcSensorDescription
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcSensorDescription,
+ ) -> None:
+ """Initialize select."""
+ super().__init__(coordinator, key, attribute, description)
+
+ if self.option_map:
+ self._attr_options = list(self.option_map.values())
+
+ @property
+ def native_value(self) -> StateType:
+ """Return value of sensor."""
+ attribute_value = self.attribute_value
+
+ if not self.option_map:
+ return attribute_value
+
+ if not isinstance(attribute_value, str):
+ return None
+
+ return self.option_map[attribute_value]
+
+
+class RpcBluTrvSensor(RpcSensor):
+ """Represent a RPC BluTrv sensor."""
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcSensorDescription,
+ ) -> None:
+ """Initialize."""
+
+ super().__init__(coordinator, key, attribute, description)
+ ble_addr: str = coordinator.device.config[key]["addr"]
+ self._attr_device_info = DeviceInfo(
+ connections={(CONNECTION_BLUETOOTH, ble_addr)}
+ )
+
+
SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
("device", "battery"): BlockSensorDescription(
key="device|battery",
@@ -770,6 +822,18 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
+ "ret_energy_pm1": RpcSensorDescription(
+ key="pm1",
+ sub_key="ret_aenergy",
+ name="Total active returned energy",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value=lambda status, _: status["total"],
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
"energy_cct": RpcSensorDescription(
key="cct",
sub_key="aenergy",
@@ -1116,6 +1180,15 @@ RPC_SENSORS: Final = {
state_class=SensorStateClass.MEASUREMENT,
available=lambda status: status is not None,
),
+ "voltmeter_value": RpcSensorDescription(
+ key="voltmeter",
+ sub_key="xvoltage",
+ name="Voltmeter value",
+ removal_condition=lambda _config, status, key: (
+ status[key].get("xvoltage") is None
+ ),
+ unit=lambda config: config["xvoltage"]["unit"] or None,
+ ),
"analoginput": RpcSensorDescription(
key="input",
sub_key="percent",
@@ -1201,6 +1274,38 @@ RPC_SENSORS: Final = {
options_fn=lambda config: config["options"],
device_class=SensorDeviceClass.ENUM,
),
+ "valve_position": RpcSensorDescription(
+ key="blutrv",
+ sub_key="pos",
+ name="Valve position",
+ translation_key="valve_position",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ removal_condition=lambda config, _status, key: config[key].get("enable", False)
+ is False,
+ entity_class=RpcBluTrvSensor,
+ ),
+ "blutrv_battery": RpcSensorDescription(
+ key="blutrv",
+ sub_key="battery",
+ name="Battery",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_class=RpcBluTrvSensor,
+ ),
+ "blutrv_rssi": RpcSensorDescription(
+ key="blutrv",
+ sub_key="rssi",
+ name="Signal strength",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_class=RpcBluTrvSensor,
+ ),
}
@@ -1306,38 +1411,6 @@ class RestSensor(ShellyRestAttributeEntity, SensorEntity):
return self.attribute_value
-class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
- """Represent a RPC sensor."""
-
- entity_description: RpcSensorDescription
-
- def __init__(
- self,
- coordinator: ShellyRpcCoordinator,
- key: str,
- attribute: str,
- description: RpcSensorDescription,
- ) -> None:
- """Initialize select."""
- super().__init__(coordinator, key, attribute, description)
-
- if self.option_map:
- self._attr_options = list(self.option_map.values())
-
- @property
- def native_value(self) -> StateType:
- """Return value of sensor."""
- attribute_value = self.attribute_value
-
- if not self.option_map:
- return attribute_value
-
- if not isinstance(attribute_value, str):
- return None
-
- return self.option_map[attribute_value]
-
-
class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor):
"""Represent a block sleeping sensor."""
diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py
index 134704cb0ff..8a33dae0938 100644
--- a/homeassistant/components/shelly/switch.py
+++ b/homeassistant/components/shelly/switch.py
@@ -120,9 +120,8 @@ def async_setup_block_entry(
relay_blocks = []
assert coordinator.device.blocks
for block in coordinator.device.blocks:
- if (
- block.type != "relay"
- or block.channel is not None
+ if block.type != "relay" or (
+ block.channel is not None
and is_block_channel_type_light(
coordinator.device.settings, int(block.channel)
)
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index df374624e3d..81766c65388 100644
--- a/homeassistant/components/shelly/utils.py
+++ b/homeassistant/components/shelly/utils.py
@@ -50,6 +50,7 @@ from .const import (
DOMAIN,
FIRMWARE_UNSUPPORTED_ISSUE_ID,
GEN1_RELEASE_URL,
+ GEN2_BETA_RELEASE_URL,
GEN2_RELEASE_URL,
LOGGER,
RPC_INPUTS_EVENTS_TYPES,
@@ -137,7 +138,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str:
else:
base = ord("1")
- return f"{entity_name} channel {chr(int(block.channel)+base)}"
+ return f"{entity_name} channel {chr(int(block.channel) + base)}"
def is_block_momentary_input(
@@ -200,7 +201,7 @@ def get_block_input_triggers(
subtype = "button"
else:
assert block.channel
- subtype = f"button{int(block.channel)+1}"
+ subtype = f"button{int(block.channel) + 1}"
if device.settings["device"]["type"] in SHBTN_MODELS:
trigger_types = SHBTN_INPUTS_EVENTS_TYPES
@@ -409,7 +410,7 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]:
continue
for trigger_type in RPC_INPUTS_EVENTS_TYPES:
- subtype = f"button{id_+1}"
+ subtype = f"button{id_ + 1}"
triggers.append((trigger_type, subtype))
return triggers
@@ -453,9 +454,14 @@ def mac_address_from_name(name: str) -> str | None:
def get_release_url(gen: int, model: str, beta: bool) -> str | None:
"""Return release URL or None."""
- if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG:
+ if (
+ beta and gen in BLOCK_GENERATIONS
+ ) or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG:
return None
+ if beta:
+ return GEN2_BETA_RELEASE_URL
+
return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL
diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py
index 867b58ad1ba..ef0f4dafd83 100644
--- a/homeassistant/components/shodan/sensor.py
+++ b/homeassistant/components/shodan/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py
index 531bbf37980..4ce596e72f0 100644
--- a/homeassistant/components/shopping_list/__init__.py
+++ b/homeassistant/components/shopping_list/__init__.py
@@ -17,7 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_NAME, Platform
from homeassistant.core import Context, HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonValueType, load_json_array
diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py
index 1a6370f4168..118287f70d2 100644
--- a/homeassistant/components/shopping_list/intent.py
+++ b/homeassistant/components/shopping_list/intent.py
@@ -3,8 +3,7 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import intent
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, intent
from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED
diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py
index 8f9190e4436..aece5675cbc 100644
--- a/homeassistant/components/sigfox/sensor.py
+++ b/homeassistant/components/sigfox/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py
index acc8309af26..222b61456c4 100644
--- a/homeassistant/components/sighthound/image_processing.py
+++ b/homeassistant/components/sighthound/image_processing.py
@@ -22,10 +22,10 @@ from homeassistant.const import (
CONF_SOURCE,
)
from homeassistant.core import HomeAssistant, split_entity_id
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.pil import draw_box
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json
index 1efd572425b..e1226fd344d 100644
--- a/homeassistant/components/sighthound/manifest.json
+++ b/homeassistant/components/sighthound/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["simplehound"],
"quality_scale": "legacy",
- "requirements": ["Pillow==11.0.0", "simplehound==0.3"]
+ "requirements": ["Pillow==11.1.0", "simplehound==0.3"]
}
diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py
index 53a255da5ff..bc007eaa689 100644
--- a/homeassistant/components/signal_messenger/notify.py
+++ b/homeassistant/components/signal_messenger/notify.py
@@ -15,7 +15,7 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py
index 16780a05704..8c906d26c23 100644
--- a/homeassistant/components/sinch/notify.py
+++ b/homeassistant/components/sinch/notify.py
@@ -23,7 +23,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_API_KEY, CONF_SENDER
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
DOMAIN = "sinch"
diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py
index 9ce6898fd93..65d7848c618 100644
--- a/homeassistant/components/siren/__init__.py
+++ b/homeassistant/components/siren/__init__.py
@@ -6,7 +6,7 @@ from datetime import timedelta
import logging
from typing import Any, TypedDict, cast, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -68,10 +68,8 @@ def process_turn_on_params(
isinstance(siren.available_tones, dict)
and tone in siren.available_tones.values()
)
- if (
- not siren.available_tones
- or tone not in siren.available_tones
- and not is_tone_dict_value
+ if not siren.available_tones or (
+ tone not in siren.available_tones and not is_tone_dict_value
):
raise ValueError(
f"Invalid tone specified for entity {siren.entity_id}: {tone}, "
diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py
index da8d670d412..1406826e471 100644
--- a/homeassistant/components/sisyphus/__init__.py
+++ b/homeassistant/components/sisyphus/__init__.py
@@ -8,8 +8,8 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py
index b0ad48ed985..7507175b321 100644
--- a/homeassistant/components/sky_hub/device_tracker.py
+++ b/homeassistant/components/sky_hub/device_tracker.py
@@ -14,8 +14,8 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py
index a55dfb2a52b..13cddf99332 100644
--- a/homeassistant/components/sky_remote/config_flow.py
+++ b/homeassistant/components/sky_remote/config_flow.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT
diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py
index 6cb5064b40e..650e62bc4a1 100644
--- a/homeassistant/components/skybeacon/sensor.py
+++ b/homeassistant/components/skybeacon/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py
index 6fce38e4774..aa67739016d 100644
--- a/homeassistant/components/slack/__init__.py
+++ b/homeassistant/components/slack/__init__.py
@@ -5,8 +5,8 @@ from __future__ import annotations
import logging
from aiohttp.client_exceptions import ClientError
-from slack import WebClient
from slack.errors import SlackApiError
+from slack_sdk.web.async_client import AsyncWebClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
@@ -40,7 +40,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Slack from a config entry."""
session = aiohttp_client.async_get_clientsession(hass)
- slack = WebClient(token=entry.data[CONF_API_KEY], run_async=True, session=session)
+ slack = AsyncWebClient(
+ token=entry.data[CONF_API_KEY], session=session
+ ) # No run_async
try:
res = await slack.auth_test()
@@ -49,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Invalid API key")
return False
raise ConfigEntryNotReady("Error while setting up integration") from ex
+
data = {
DATA_CLIENT: slack,
ATTR_URL: res[ATTR_URL],
diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py
index 7f6d7288606..fcdc2e8b362 100644
--- a/homeassistant/components/slack/config_flow.py
+++ b/homeassistant/components/slack/config_flow.py
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
-from slack import WebClient
from slack.errors import SlackApiError
+from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -57,10 +57,10 @@ class SlackFlowHandler(ConfigFlow, domain=DOMAIN):
async def _async_try_connect(
self, token: str
- ) -> tuple[str, None] | tuple[None, dict[str, str]]:
+ ) -> tuple[str, None] | tuple[None, AsyncSlackResponse]:
"""Try connecting to Slack."""
session = aiohttp_client.async_get_clientsession(self.hass)
- client = WebClient(token=token, run_async=True, session=session)
+ client = AsyncWebClient(token=token, session=session) # No run_async
try:
info = await client.auth_test()
diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py
index 7147186ee9b..30218360054 100644
--- a/homeassistant/components/slack/entity.py
+++ b/homeassistant/components/slack/entity.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from slack import WebClient
+from slack_sdk.web.async_client import AsyncWebClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -14,21 +14,18 @@ from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN
class SlackEntity(Entity):
"""Representation of a Slack entity."""
- _attr_attribution = "Data provided by Slack"
- _attr_has_entity_name = True
-
def __init__(
self,
- data: dict[str, str | WebClient],
+ data: dict[str, AsyncWebClient],
description: EntityDescription,
entry: ConfigEntry,
) -> None:
"""Initialize a Slack entity."""
- self._client = data[DATA_CLIENT]
+ self._client: AsyncWebClient = data[DATA_CLIENT]
self.entity_description = description
self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}"
self._attr_device_info = DeviceInfo(
- configuration_url=data[ATTR_URL],
+ configuration_url=str(data[ATTR_URL]),
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer=DEFAULT_NAME,
diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json
index 1b35db6f061..3b2322283fe 100644
--- a/homeassistant/components/slack/manifest.json
+++ b/homeassistant/components/slack/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["slack"],
- "requirements": ["slackclient==2.5.0"]
+ "requirements": ["slack_sdk==3.33.4"]
}
diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py
index 28f9dd203ff..16dd212301a 100644
--- a/homeassistant/components/slack/notify.py
+++ b/homeassistant/components/slack/notify.py
@@ -5,13 +5,13 @@ from __future__ import annotations
import asyncio
import logging
import os
-from typing import Any, TypedDict
+from typing import Any, TypedDict, cast
from urllib.parse import urlparse
-from aiohttp import BasicAuth, FormData
+from aiohttp import BasicAuth
from aiohttp.client_exceptions import ClientError
-from slack import WebClient
from slack.errors import SlackApiError
+from slack_sdk.web.async_client import AsyncWebClient
import voluptuous as vol
from homeassistant.components.notify import (
@@ -38,6 +38,7 @@ from .const import (
DATA_CLIENT,
SLACK_DATA,
)
+from .utils import upload_file_to_slack
_LOGGER = logging.getLogger(__name__)
@@ -136,7 +137,7 @@ class SlackNotificationService(BaseNotificationService):
def __init__(
self,
hass: HomeAssistant,
- client: WebClient,
+ client: AsyncWebClient,
config: dict[str, str],
) -> None:
"""Initialize."""
@@ -160,17 +161,23 @@ class SlackNotificationService(BaseNotificationService):
parsed_url = urlparse(path)
filename = os.path.basename(parsed_url.path)
- try:
- await self._client.files_upload(
- channels=",".join(targets),
- file=path,
- filename=filename,
- initial_comment=message,
- title=title or filename,
- thread_ts=thread_ts or "",
- )
- except (SlackApiError, ClientError) as err:
- _LOGGER.error("Error while uploading file-based message: %r", err)
+ channel_ids = [await self._async_get_channel_id(target) for target in targets]
+ channel_ids = [cid for cid in channel_ids if cid] # Remove None values
+
+ if not channel_ids:
+ _LOGGER.error("No valid channel IDs resolved for targets: %s", targets)
+ return
+
+ await upload_file_to_slack(
+ client=self._client,
+ channel_ids=channel_ids,
+ file_content=None,
+ file_path=path,
+ filename=filename,
+ title=title,
+ message=message,
+ thread_ts=thread_ts,
+ )
async def _async_send_remote_file_message(
self,
@@ -183,12 +190,7 @@ class SlackNotificationService(BaseNotificationService):
username: str | None = None,
password: str | None = None,
) -> None:
- """Upload a remote file (with message) to Slack.
-
- Note that we bypass the python-slackclient WebClient and use aiohttp directly,
- as the former would require us to download the entire remote file into memory
- first before uploading it to Slack.
- """
+ """Upload a remote file (with message) to Slack."""
if not self._hass.config.is_allowed_external_url(url):
_LOGGER.error("URL is not allowed: %s", url)
return
@@ -196,36 +198,35 @@ class SlackNotificationService(BaseNotificationService):
filename = _async_get_filename_from_url(url)
session = aiohttp_client.async_get_clientsession(self._hass)
+ # Fetch the remote file
kwargs: AuthDictT = {}
- if username and password is not None:
+ if username and password:
kwargs = {"auth": BasicAuth(username, password=password)}
- resp = await session.request("get", url, **kwargs)
-
try:
- resp.raise_for_status()
+ async with session.get(url, **kwargs) as resp:
+ resp.raise_for_status()
+ file_content = await resp.read()
except ClientError as err:
_LOGGER.error("Error while retrieving %s: %r", url, err)
return
- form_data: FormDataT = {
- "channels": ",".join(targets),
- "filename": filename,
- "initial_comment": message,
- "title": title or filename,
- "token": self._client.token,
- }
+ channel_ids = [await self._async_get_channel_id(target) for target in targets]
+ channel_ids = [cid for cid in channel_ids if cid] # Remove None values
- if thread_ts:
- form_data["thread_ts"] = thread_ts
+ if not channel_ids:
+ _LOGGER.error("No valid channel IDs resolved for targets: %s", targets)
+ return
- data = FormData(form_data, charset="utf-8")
- data.add_field("file", resp.content, filename=filename)
-
- try:
- await session.post("https://slack.com/api/files.upload", data=data)
- except ClientError as err:
- _LOGGER.error("Error while uploading file message: %r", err)
+ await upload_file_to_slack(
+ client=self._client,
+ channel_ids=channel_ids,
+ file_content=file_content,
+ filename=filename,
+ title=title,
+ message=message,
+ thread_ts=thread_ts,
+ )
async def _async_send_text_only_message(
self,
@@ -327,3 +328,46 @@ class SlackNotificationService(BaseNotificationService):
title,
thread_ts=data.get(ATTR_THREAD_TS),
)
+
+ async def _async_get_channel_id(self, channel_name: str) -> str | None:
+ """Get the Slack channel ID from the channel name.
+
+ This method retrieves the channel ID for a given Slack channel name by
+ querying the Slack API. It handles both public and private channels.
+ Including this so users can provide channel names instead of IDs.
+
+ Args:
+ channel_name (str): The name of the Slack channel.
+
+ Returns:
+ str | None: The ID of the Slack channel if found, otherwise None.
+
+ Raises:
+ SlackApiError: If there is an error while communicating with the Slack API.
+
+ """
+ try:
+ # Remove # if present
+ channel_name = channel_name.lstrip("#")
+
+ # Get channel list
+ # Multiple types is not working. Tested here: https://api.slack.com/methods/conversations.list/test
+ # response = await self._client.conversations_list(types="public_channel,private_channel")
+ #
+ # Workaround for the types parameter not working
+ channels = []
+ for channel_type in ("public_channel", "private_channel"):
+ response = await self._client.conversations_list(types=channel_type)
+ channels.extend(response["channels"])
+
+ # Find channel ID
+ for channel in channels:
+ if channel["name"] == channel_name:
+ return cast(str, channel["id"])
+
+ _LOGGER.error("Channel %s not found", channel_name)
+
+ except SlackApiError as err:
+ _LOGGER.error("Error getting channel ID: %r", err)
+
+ return None
diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py
index 9e3beaadd8b..ca8c9830818 100644
--- a/homeassistant/components/slack/sensor.py
+++ b/homeassistant/components/slack/sensor.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from slack import WebClient
+from slack_sdk.web.async_client import AsyncWebClient
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA
from .entity import SlackEntity
@@ -43,7 +43,7 @@ async def async_setup_entry(
class SlackSensorEntity(SlackEntity, SensorEntity):
"""Representation of a Slack sensor."""
- _client: WebClient
+ _client: AsyncWebClient
async def async_update(self) -> None:
"""Get the latest status."""
diff --git a/homeassistant/components/slack/utils.py b/homeassistant/components/slack/utils.py
new file mode 100644
index 00000000000..7619d7d265f
--- /dev/null
+++ b/homeassistant/components/slack/utils.py
@@ -0,0 +1,62 @@
+"""Utils for the Slack integration."""
+
+import logging
+
+import aiofiles
+from slack_sdk.errors import SlackApiError
+from slack_sdk.web.async_client import AsyncWebClient
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def upload_file_to_slack(
+ client: AsyncWebClient,
+ channel_ids: list[str | None],
+ file_content: bytes | str | None,
+ filename: str,
+ title: str | None,
+ message: str,
+ thread_ts: str | None,
+ file_path: str | None = None, # Allow passing a file path
+) -> None:
+ """Upload a file to Slack for the specified channel IDs.
+
+ Args:
+ client (AsyncWebClient): The Slack WebClient instance.
+ channel_ids (list[str | None]): List of channel IDs to upload the file to.
+ file_content (Union[bytes, str, None]): Content of the file (local or remote). If None, file_path is used.
+ filename (str): The file's name.
+ title (str | None): Title of the file in Slack.
+ message (str): Initial comment to accompany the file.
+ thread_ts (str | None): Thread timestamp for threading messages.
+ file_path (str | None): Path to the local file to be read if file_content is None.
+
+ Raises:
+ SlackApiError: If the Slack API call fails.
+ OSError: If there is an error reading the file.
+
+ """
+ if file_content is None and file_path:
+ # Read file asynchronously if file_content is not provided
+ try:
+ async with aiofiles.open(file_path, "rb") as file:
+ file_content = await file.read()
+ except OSError as os_err:
+ _LOGGER.error("Error reading file %s: %r", file_path, os_err)
+ return
+
+ for channel_id in channel_ids:
+ try:
+ await client.files_upload_v2(
+ channel=channel_id,
+ file=file_content,
+ filename=filename,
+ title=title or filename,
+ initial_comment=message,
+ thread_ts=thread_ts or "",
+ )
+ _LOGGER.info("Successfully uploaded file to channel %s", channel_id)
+ except SlackApiError as err:
+ _LOGGER.error(
+ "Error while uploading file to channel %s: %r", channel_id, err
+ )
diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py
index 6506be06e72..4f54b4cd305 100644
--- a/homeassistant/components/sleepiq/__init__.py
+++ b/homeassistant/components/sleepiq/__init__.py
@@ -17,9 +17,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py
index e4fa60a4a43..905ceab18bd 100644
--- a/homeassistant/components/sleepiq/number.py
+++ b/homeassistant/components/sleepiq/number.py
@@ -58,14 +58,14 @@ def _get_actuator_name(bed: SleepIQBed, actuator: SleepIQActuator) -> str:
f" {bed.name} {actuator.side_full} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}"
)
- return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" # type: ignore[unreachable]
+ return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}"
def _get_actuator_unique_id(bed: SleepIQBed, actuator: SleepIQActuator) -> str:
if actuator.side:
return f"{bed.id}_{actuator.side.value}_{actuator.actuator}"
- return f"{bed.id}_{actuator.actuator}" # type: ignore[unreachable]
+ return f"{bed.id}_{actuator.actuator}"
def _get_sleeper_name(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str:
diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py
index 795cd4f1c2e..faca7cb3f2b 100644
--- a/homeassistant/components/slide_local/button.py
+++ b/homeassistant/components/slide_local/button.py
@@ -44,7 +44,7 @@ class SlideButton(SlideEntity, ButtonEntity):
def __init__(self, coordinator: SlideCoordinator) -> None:
"""Initialize the slide button."""
super().__init__(coordinator)
- self._attr_unique_id = f"{coordinator.data["mac"]}-calibrate"
+ self._attr_unique_id = f"{coordinator.data['mac']}-calibrate"
async def async_press(self) -> None:
"""Send out a calibrate command."""
diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py
index a4255f0769f..4ceb347568f 100644
--- a/homeassistant/components/slide_local/config_flow.py
+++ b/homeassistant/components/slide_local/config_flow.py
@@ -14,11 +14,11 @@ from goslideapi.goslideapi import (
)
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import SlideConfigEntry
from .const import CONF_INVERT_POSITION, DOMAIN
@@ -63,7 +63,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN):
return {"base": "cannot_connect"}
except (AuthenticationFailed, DigestAuthCalcError):
return {"base": "invalid_auth"}
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception("Exception occurred during connection test")
return {"base": "unknown"}
@@ -85,7 +85,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN):
return {"base": "cannot_connect"}
except (AuthenticationFailed, DigestAuthCalcError):
return {"base": "invalid_auth"}
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception("Exception occurred during connection test")
return {"base": "unknown"}
diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json
index b5fe88255a7..67514ff0d50 100644
--- a/homeassistant/components/slide_local/strings.json
+++ b/homeassistant/components/slide_local/strings.json
@@ -32,7 +32,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"discovery_connection_failed": "The setup of the discovered device failed with the following error: {error}. Please try to set it up manually.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "unique_id_mismatch": "The mac address of the device ({mac}) does not match the previous mac address."
+ "unique_id_mismatch": "The MAC address of the device ({mac}) does not match the previous MAC address."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -49,7 +49,7 @@
"invert_position": "Invert position"
},
"data_description": {
- "invert_position": "Invert the position of your slide cover."
+ "invert_position": "Inverts the position of your Slide cover."
}
}
}
diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py
index f1c33f9a76f..0471dfcc4e6 100644
--- a/homeassistant/components/slide_local/switch.py
+++ b/homeassistant/components/slide_local/switch.py
@@ -47,7 +47,7 @@ class SlideSwitch(SlideEntity, SwitchEntity):
def __init__(self, coordinator: SlideCoordinator) -> None:
"""Initialize the slide switch."""
super().__init__(coordinator)
- self._attr_unique_id = f"{coordinator.data["mac"]}-touchgo"
+ self._attr_unique_id = f"{coordinator.data['mac']}-touchgo"
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py
index 37fb4d72284..6aae74922e4 100644
--- a/homeassistant/components/sma/__init__.py
+++ b/homeassistant/components/sma/__init__.py
@@ -72,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model=sma_device_info["type"],
name=sma_device_info["name"],
sw_version=sma_device_info["sw_version"],
+ serial_number=sma_device_info["serial"],
)
# Define the coordinator
diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py
index 4b3e01a79a8..3f5eb635989 100644
--- a/homeassistant/components/sma/config_flow.py
+++ b/homeassistant/components/sma/config_flow.py
@@ -11,8 +11,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import CONF_GROUP, DOMAIN, GROUPS
diff --git a/homeassistant/components/sma/diagnostics.py b/homeassistant/components/sma/diagnostics.py
new file mode 100644
index 00000000000..9c17cb0d2a9
--- /dev/null
+++ b/homeassistant/components/sma/diagnostics.py
@@ -0,0 +1,35 @@
+"""Diagnostics support for SMA."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+TO_REDACT = {CONF_PASSWORD}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for the config entry."""
+ ent_reg = er.async_get(hass)
+ entities = [
+ entity.entity_id
+ for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
+ ]
+
+ entity_states = {entity: hass.states.get(entity) for entity in entities}
+
+ entry_dict = entry.as_dict()
+ if "data" in entry_dict:
+ entry_dict["data"] = async_redact_data(entry_dict["data"], TO_REDACT)
+
+ return {
+ "entry": entry_dict,
+ "entities": entity_states,
+ }
diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json
index 070320fa976..8024aad82d6 100644
--- a/homeassistant/components/sma/manifest.json
+++ b/homeassistant/components/sma/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "sma",
"name": "SMA Solar",
- "codeowners": ["@kellerza", "@rklomp"],
+ "codeowners": ["@kellerza", "@rklomp", "@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sma",
"iot_class": "local_polling",
"loggers": ["pysma"],
- "requirements": ["pysma==0.7.3"]
+ "requirements": ["pysma==0.7.5"]
}
diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py
index 302c4f6b197..863f15a9a17 100644
--- a/homeassistant/components/sma/sensor.py
+++ b/homeassistant/components/sma/sensor.py
@@ -48,6 +48,12 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = {
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ "operating_status": SensorEntityDescription(
+ key="operating_status",
+ name="Operating Status",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
"inverter_condition": SensorEntityDescription(
key="inverter_condition",
name="Inverter Condition",
diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py
index 4f7a71218ab..01b69a76b28 100644
--- a/homeassistant/components/smappee/config_flow.py
+++ b/homeassistant/components/smappee/config_flow.py
@@ -6,10 +6,10 @@ from typing import Any
from pysmappee import helper, mqtt
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS
from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import api
from .const import (
@@ -43,7 +43,7 @@ class SmappeeFlowHandler(
return logging.getLogger(__name__)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py
index 0d043804c3d..0e1e99aa444 100644
--- a/homeassistant/components/smarty/__init__.py
+++ b/homeassistant/components/smarty/__init__.py
@@ -9,8 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_NAME, Platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import issue_registry as ir
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py
index 9d847003a59..48b169c104e 100644
--- a/homeassistant/components/smarty/sensor.py
+++ b/homeassistant/components/smarty/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .coordinator import SmartyConfigEntry, SmartyCoordinator
from .entity import SmartyEntity
diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py
index 94bdfcc4559..59b32948879 100644
--- a/homeassistant/components/smhi/__init__.py
+++ b/homeassistant/components/smhi/__init__.py
@@ -32,6 +32,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
+
+ if entry.version > 3:
+ # Downgrade from future version
+ return False
+
if entry.version == 1:
new_data = {
CONF_NAME: entry.data[CONF_NAME],
@@ -40,8 +45,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
CONF_LONGITUDE: entry.data[CONF_LONGITUDE],
},
}
+ hass.config_entries.async_update_entry(entry, data=new_data, version=2)
- if not hass.config_entries.async_update_entry(entry, data=new_data, version=2):
- return False
+ if entry.version == 2:
+ new_data = entry.data.copy()
+ new_data.pop(CONF_NAME)
+ hass.config_entries.async_update_entry(entry, data=new_data, version=3)
return True
diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py
index 2992b176f24..2521df3a333 100644
--- a/homeassistant/components/smhi/config_flow.py
+++ b/homeassistant/components/smhi/config_flow.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME
+from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
aiohttp_client,
@@ -38,7 +38,7 @@ async def async_check_location(
class SmhiFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for SMHI component."""
- VERSION = 2
+ VERSION = 3
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -58,10 +58,6 @@ class SmhiFlowHandler(ConfigFlow, domain=DOMAIN):
):
name = HOME_LOCATION_NAME
- user_input[CONF_NAME] = (
- HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME
- )
-
await self.async_set_unique_id(f"{lat}-{lon}")
self._abort_if_unique_id_configured()
return self.async_create_entry(title=name, data=user_input)
diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json
index 76f9812e815..645ace41cab 100644
--- a/homeassistant/components/smhi/manifest.json
+++ b/homeassistant/components/smhi/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smhi",
"iot_class": "cloud_polling",
"loggers": ["smhi"],
- "requirements": ["smhi-pkg==1.0.18"]
+ "requirements": ["smhi-pkg==1.0.19"]
}
diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py
index 3d5642a2784..d43ca4465ae 100644
--- a/homeassistant/components/smhi/weather.py
+++ b/homeassistant/components/smhi/weather.py
@@ -48,7 +48,6 @@ from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
- CONF_NAME,
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
@@ -60,7 +59,7 @@ from homeassistant.helpers import aiohttp_client, sun
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
-from homeassistant.util import Throttle, dt as dt_util, slugify
+from homeassistant.util import Throttle, dt as dt_util
from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT
@@ -103,17 +102,15 @@ async def async_setup_entry(
) -> None:
"""Add a weather entity from map location."""
location = config_entry.data
- name = slugify(location[CONF_NAME])
session = aiohttp_client.async_get_clientsession(hass)
entity = SmhiWeather(
- location[CONF_NAME],
location[CONF_LOCATION][CONF_LATITUDE],
location[CONF_LOCATION][CONF_LONGITUDE],
session=session,
)
- entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name)
+ entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title)
async_add_entities([entity], True)
@@ -136,7 +133,6 @@ class SmhiWeather(WeatherEntity):
def __init__(
self,
- name: str,
latitude: str,
longitude: str,
session: aiohttp.ClientSession,
@@ -152,7 +148,6 @@ class SmhiWeather(WeatherEntity):
identifiers={(DOMAIN, f"{latitude}, {longitude}")},
manufacturer="SMHI",
model="v2",
- name=name,
configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html",
)
diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py
index 92b543e0441..667e6e2884b 100644
--- a/homeassistant/components/smlight/config_flow.py
+++ b/homeassistant/components/smlight/config_flow.py
@@ -6,14 +6,16 @@ from collections.abc import Mapping
from typing import Any
from pysmlight import Api2
+from pysmlight.const import Devices
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
import voluptuous as vol
-from homeassistant.components import zeroconf
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -34,11 +36,9 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema(
class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SMLIGHT Zigbee."""
- host: str
-
- def __init__(self) -> None:
- """Initialize the config flow."""
- self.client: Api2
+ _host: str
+ _device_name: str
+ client: Api2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -47,10 +47,17 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
- self.host = user_input[CONF_HOST]
- self.client = Api2(self.host, session=async_get_clientsession(self.hass))
+ self._host = user_input[CONF_HOST]
+ self.client = Api2(self._host, session=async_get_clientsession(self.hass))
try:
+ info = await self.client.get_info()
+ self._host = str(info.device_ip)
+ self._device_name = str(info.hostname)
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
@@ -70,6 +77,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
+ info = await self.client.get_info()
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
@@ -82,18 +94,17 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Lan coordinator."""
- local_name = discovery_info.hostname[:-1]
- node_name = local_name.removesuffix(".local")
+ mac: str | None = discovery_info.properties.get("mac")
+ self._device_name = discovery_info.hostname.removesuffix(".local.")
+ self._host = discovery_info.host
- self.host = local_name
- self.context["title_placeholders"] = {CONF_NAME: node_name}
- self.client = Api2(self.host, session=async_get_clientsession(self.hass))
+ self.context["title_placeholders"] = {CONF_NAME: self._device_name}
+ self.client = Api2(self._host, session=async_get_clientsession(self.hass))
- mac = discovery_info.properties.get("mac")
- # fallback for legacy firmware
+ # fallback for legacy firmware older than v2.3.x
if mac is None:
try:
info = await self.client.get_info()
@@ -103,7 +114,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
mac = info.MAC
await self.async_set_unique_id(format_mac(mac))
- self._abort_if_unique_id_configured()
+ self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
return await self.async_step_confirm_discovery()
@@ -114,8 +125,12 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
- user_input[CONF_HOST] = self.host
try:
+ info = await self.client.get_info()
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
@@ -129,7 +144,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="confirm_discovery",
- description_placeholders={"host": self.host},
+ description_placeholders={"host": self._device_name},
errors=errors,
)
@@ -138,8 +153,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle reauth when API Authentication failed."""
- self.host = entry_data[CONF_HOST]
- self.client = Api2(self.host, session=async_get_clientsession(self.hass))
+ self._host = entry_data[CONF_HOST]
+ self.client = Api2(self._host, session=async_get_clientsession(self.hass))
return await self.async_step_reauth_confirm()
@@ -169,6 +184,16 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle DHCP discovery."""
+ await self.async_set_unique_id(format_mac(discovery_info.macaddress))
+ self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
+ # This should never happen since we only listen to DHCP requests
+ # for configured devices.
+ return self.async_abort(reason="already_configured")
+
async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool:
"""Check if auth required and attempt to authenticate."""
if await self.client.check_auth_needed():
@@ -183,12 +208,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any]
) -> ConfigFlowResult:
info = await self.client.get_info()
- await self.async_set_unique_id(format_mac(info.MAC))
- self._abort_if_unique_id_configured()
- if user_input.get(CONF_HOST) is None:
- user_input[CONF_HOST] = self.host
+ await self.async_set_unique_id(
+ format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER
+ )
+ self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
+
+ user_input[CONF_HOST] = self._host
assert info.model is not None
- title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model
+ title = self._device_name or info.model
return self.async_create_entry(title=title, data=user_input)
diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py
index 669094b2441..0a45363f8ad 100644
--- a/homeassistant/components/smlight/const.py
+++ b/homeassistant/components/smlight/const.py
@@ -9,7 +9,7 @@ ATTR_MANUFACTURER = "SMLIGHT"
DATA_COORDINATOR = "data"
FIRMWARE_COORDINATOR = "firmware"
-SCAN_FIRMWARE_INTERVAL = timedelta(hours=6)
+SCAN_FIRMWARE_INTERVAL = timedelta(hours=24)
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(seconds=300)
SCAN_INTERNET_INTERVAL = timedelta(minutes=15)
diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py
index 5b38ec4a89e..6be36439e9f 100644
--- a/homeassistant/components/smlight/coordinator.py
+++ b/homeassistant/components/smlight/coordinator.py
@@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]):
async def _internal_update_data(self) -> SmFwData:
"""Fetch data from the SMLIGHT device."""
info = await self.client.get_info()
+ esp_firmware = None
+ zb_firmware = None
- return SmFwData(
- info=info,
- esp_firmware=await self.client.get_firmware_version(info.fw_channel),
- zb_firmware=await self.client.get_firmware_version(
+ try:
+ esp_firmware = await self.client.get_firmware_version(info.fw_channel)
+ zb_firmware = await self.client.get_firmware_version(
info.fw_channel, device=info.model, mode="zigbee"
- ),
- )
+ )
+ except SmlightConnectionError as err:
+ self.async_set_update_error(err)
+
+ return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware)
diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json
index cb791ac111b..4bc2f36dddf 100644
--- a/homeassistant/components/smlight/manifest.json
+++ b/homeassistant/components/smlight/manifest.json
@@ -3,10 +3,15 @@
"name": "SMLIGHT SLZB",
"codeowners": ["@tl-sl"],
"config_flow": true,
+ "dhcp": [
+ {
+ "registered_devices": true
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["pysmlight==0.1.4"],
+ "requirements": ["pysmlight==0.1.7"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json
index 1e6a533beef..21ff5098d27 100644
--- a/homeassistant/components/smlight/strings.json
+++ b/homeassistant/components/smlight/strings.json
@@ -38,7 +38,8 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_failed": "[%key:common::config_flow::error::invalid_auth%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "unsupported_device": "This device is not yet supported by the SMLIGHT integration"
}
},
"entity": {
diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py
index 5d19a705d87..e86b22690a4 100644
--- a/homeassistant/components/smtp/notify.py
+++ b/homeassistant/components/smtp/notify.py
@@ -34,10 +34,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.reload import setup_reload_service
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.ssl import client_context
from .const import (
diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json
index b5673910595..685b4a0dd11 100644
--- a/homeassistant/components/snapcast/strings.json
+++ b/homeassistant/components/snapcast/strings.json
@@ -24,7 +24,7 @@
"services": {
"join": {
"name": "Join",
- "description": "Groups players together.",
+ "description": "Groups players together in a single group.",
"fields": {
"master": {
"name": "Master",
@@ -38,23 +38,23 @@
},
"unjoin": {
"name": "Unjoin",
- "description": "Unjoins the player from a group."
+ "description": "Removes one or more players from a group."
},
"snapshot": {
"name": "Snapshot",
- "description": "Takes a snapshot of the media player."
+ "description": "Takes a snapshot of what is currently playing on a media player."
},
"restore": {
"name": "Restore",
- "description": "Restores a snapshot of the media player."
+ "description": "Restores a previously taken snapshot of a media player."
},
"set_latency": {
"name": "Set latency",
- "description": "Sets client set_latency.",
+ "description": "Sets the latency of a speaker.",
"fields": {
"latency": {
"name": "Latency",
- "description": "Latency in master."
+ "description": "Latency in milliseconds."
}
}
}
diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py
index 3c4a0a0725c..f69c844f191 100644
--- a/homeassistant/components/snmp/device_tracker.py
+++ b/homeassistant/components/snmp/device_tracker.py
@@ -24,7 +24,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -172,7 +172,7 @@ class SnmpScanner(DeviceScanner):
_LOGGER.error(
"SNMP error: %s at %s",
errstatus.prettyPrint(),
- errindex and res[int(errindex) - 1][0] or "?",
+ (errindex and res[int(errindex) - 1][0]) or "?",
)
return None
diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py
index 4586d0600e9..0baecd68ec4 100644
--- a/homeassistant/components/snmp/sensor.py
+++ b/homeassistant/components/snmp/sensor.py
@@ -37,7 +37,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import (
diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py
index 92e27daed6c..fd405567d60 100644
--- a/homeassistant/components/snmp/switch.py
+++ b/homeassistant/components/snmp/switch.py
@@ -44,7 +44,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -264,7 +264,7 @@ class SnmpSwitch(SwitchEntity):
_LOGGER.error(
"SNMP error: %s at %s",
errstatus.prettyPrint(),
- errindex and restable[-1][int(errindex) - 1] or "?",
+ (errindex and restable[-1][int(errindex) - 1]) or "?",
)
else:
for resrow in restable:
diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py
index a7940aa34b5..80c418ef132 100644
--- a/homeassistant/components/solaredge_local/sensor.py
+++ b/homeassistant/components/solaredge_local/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py
index 11f268db32a..bf2bc849111 100644
--- a/homeassistant/components/solarlog/coordinator.py
+++ b/homeassistant/components/solarlog/coordinator.py
@@ -19,8 +19,8 @@ from solarlog_cli.solarlog_models import SolarlogData
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py
index e6c60667869..5baead641fc 100644
--- a/homeassistant/components/solax/config_flow.py
+++ b/homeassistant/components/solax/config_flow.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json
index 925f11e4c65..5509901ae02 100644
--- a/homeassistant/components/solax/manifest.json
+++ b/homeassistant/components/solax/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "solax",
"name": "SolaX Power",
- "codeowners": ["@squishykid"],
+ "codeowners": ["@squishykid", "@Darsstar"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/solax",
"iot_class": "local_polling",
diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py
index 9ffe5539ff3..127b51338ee 100644
--- a/homeassistant/components/soma/__init__.py
+++ b/homeassistant/components/soma/__init__.py
@@ -9,7 +9,7 @@ from homeassistant import config_entries
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 import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import API, DEVICES, DOMAIN, HOST, PORT
diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py
index 50f7d34e406..e64fee00f16 100644
--- a/homeassistant/components/soma/cover.py
+++ b/homeassistant/components/soma/cover.py
@@ -76,7 +76,7 @@ class SomaTilt(SomaEntity, CoverEntity):
response = self.api.set_shade_position(self.device["mac"], 100)
if not is_api_response_success(response):
raise HomeAssistantError(
- f'Error while closing the cover ({self.name}): {response["msg"]}'
+ f"Error while closing the cover ({self.name}): {response['msg']}"
)
self.set_position(0)
@@ -85,7 +85,7 @@ class SomaTilt(SomaEntity, CoverEntity):
response = self.api.set_shade_position(self.device["mac"], -100)
if not is_api_response_success(response):
raise HomeAssistantError(
- f'Error while opening the cover ({self.name}): {response["msg"]}'
+ f"Error while opening the cover ({self.name}): {response['msg']}"
)
self.set_position(100)
@@ -94,7 +94,7 @@ class SomaTilt(SomaEntity, CoverEntity):
response = self.api.stop_shade(self.device["mac"])
if not is_api_response_success(response):
raise HomeAssistantError(
- f'Error while stopping the cover ({self.name}): {response["msg"]}'
+ f"Error while stopping the cover ({self.name}): {response['msg']}"
)
# Set cover position to some value where up/down are both enabled
self.set_position(50)
@@ -109,7 +109,7 @@ class SomaTilt(SomaEntity, CoverEntity):
if not is_api_response_success(response):
raise HomeAssistantError(
f"Error while setting the cover position ({self.name}):"
- f' {response["msg"]}'
+ f" {response['msg']}"
)
self.set_position(kwargs[ATTR_TILT_POSITION])
@@ -152,7 +152,7 @@ class SomaShade(SomaEntity, CoverEntity):
response = self.api.set_shade_position(self.device["mac"], 100)
if not is_api_response_success(response):
raise HomeAssistantError(
- f'Error while closing the cover ({self.name}): {response["msg"]}'
+ f"Error while closing the cover ({self.name}): {response['msg']}"
)
def open_cover(self, **kwargs: Any) -> None:
@@ -160,7 +160,7 @@ class SomaShade(SomaEntity, CoverEntity):
response = self.api.set_shade_position(self.device["mac"], 0)
if not is_api_response_success(response):
raise HomeAssistantError(
- f'Error while opening the cover ({self.name}): {response["msg"]}'
+ f"Error while opening the cover ({self.name}): {response['msg']}"
)
def stop_cover(self, **kwargs: Any) -> None:
@@ -168,7 +168,7 @@ class SomaShade(SomaEntity, CoverEntity):
response = self.api.stop_shade(self.device["mac"])
if not is_api_response_success(response):
raise HomeAssistantError(
- f'Error while stopping the cover ({self.name}): {response["msg"]}'
+ f"Error while stopping the cover ({self.name}): {response['msg']}"
)
# Set cover position to some value where up/down are both enabled
self.set_position(50)
@@ -182,7 +182,7 @@ class SomaShade(SomaEntity, CoverEntity):
if not is_api_response_success(response):
raise HomeAssistantError(
f"Error while setting the cover position ({self.name}):"
- f' {response["msg"]}'
+ f" {response['msg']}"
)
async def async_update(self) -> None:
diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py
index c2d85160175..a806d581aec 100644
--- a/homeassistant/components/somfy_mylink/config_flow.py
+++ b/homeassistant/components/somfy_mylink/config_flow.py
@@ -9,7 +9,6 @@ from typing import Any
from somfy_mylink_synergy import SomfyMyLinkSynergy
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
@@ -21,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_REVERSE,
@@ -69,7 +69,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN):
self.ip_address: str | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py
index 2d807bcf140..25fc736212b 100644
--- a/homeassistant/components/sonarr/coordinator.py
+++ b/homeassistant/components/sonarr/coordinator.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DOMAIN, LOGGER
diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py
index bdb647de39c..fa7d0aa7756 100644
--- a/homeassistant/components/sonarr/sensor.py
+++ b/homeassistant/components/sonarr/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator
@@ -67,7 +67,7 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]:
remaining = 1 if item.size == 0 else item.sizeleft / item.size
remaining_pct = 100 * (1 - remaining)
identifier = (
- f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}"
+ f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}"
)
attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%"
return attrs
@@ -120,7 +120,8 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
value_fn=len,
attributes_fn=lambda data: {
i.title: (
- f"{getattr(i.statistics,'episodeFileCount', 0)}/{getattr(i.statistics, 'episodeCount', 0)} Episodes"
+ f"{getattr(i.statistics, 'episodeFileCount', 0)}/"
+ f"{getattr(i.statistics, 'episodeCount', 0)} Episodes"
)
for i in data
},
diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py
index 1c13013108f..e71454f0aa8 100644
--- a/homeassistant/components/songpal/config_flow.py
+++ b/homeassistant/components/songpal/config_flow.py
@@ -9,9 +9,13 @@ from urllib.parse import urlparse
from songpal import Device, SongpalException
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .const import CONF_ENDPOINT, DOMAIN
@@ -99,15 +103,15 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Songpal device."""
- await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN])
+ await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
self._abort_if_unique_id_configured()
_LOGGER.debug("Discovered: %s", discovery_info)
- friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
+ friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]
hostname = urlparse(discovery_info.ssdp_location).hostname
scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"]
endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"]
diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py
index 82e4a5ebfba..d530fa21e39 100644
--- a/homeassistant/components/sonos/__init__.py
+++ b/homeassistant/components/sonos/__init__.py
@@ -34,6 +34,11 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later, async_track_time_interval
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
@@ -500,9 +505,9 @@ class SonosDiscoveryManager:
@callback
def _async_ssdp_discovered_player(
- self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
+ self, info: SsdpServiceInfo, change: ssdp.SsdpChange
) -> None:
- uid = info.upnp[ssdp.ATTR_UPNP_UDN]
+ uid = info.upnp[ATTR_UPNP_UDN]
if not uid.startswith("uuid:RINCON_"):
return
uid = uid[5:]
@@ -521,7 +526,7 @@ class SonosDiscoveryManager:
cast(str, urlparse(info.ssdp_location).hostname),
uid,
info.ssdp_headers.get("X-RINCON-BOOTSEQ"),
- cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)),
+ cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)),
None,
)
@@ -529,7 +534,7 @@ class SonosDiscoveryManager:
def async_discovered_player(
self,
source: str,
- info: ssdp.SsdpServiceInfo,
+ info: SsdpServiceInfo,
discovered_ip: str,
uid: str,
boot_seqnum: str | int | None,
diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py
index a8ace6e35c5..057cdb8ec08 100644
--- a/homeassistant/components/sonos/config_flow.py
+++ b/homeassistant/components/sonos/config_flow.py
@@ -1,12 +1,12 @@
"""Config flow for SONOS."""
from collections.abc import Awaitable
-import dataclasses
-from homeassistant.components import ssdp, zeroconf
+from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN, UPNP_ST
from .helpers import hostname_to_uid
@@ -25,21 +25,21 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO
super().__init__(DOMAIN, "Sonos", _async_has_devices)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf."""
hostname = discovery_info.hostname
if hostname is None or not hostname.lower().startswith("sonos"):
return self.async_abort(reason="not_sonos_device")
- await self.async_set_unique_id(self._domain, raise_on_progress=False)
- host = discovery_info.host
- mdns_name = discovery_info.name
- properties = discovery_info.properties
- boot_seqnum = properties.get("bootseq")
- model = properties.get("model")
- uid = hostname_to_uid(hostname)
if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER):
+ host = discovery_info.host
+ mdns_name = discovery_info.name
+ properties = discovery_info.properties
+ boot_seqnum = properties.get("bootseq")
+ model = properties.get("model")
+ uid = hostname_to_uid(hostname)
discovery_manager.async_discovered_player(
"Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name
)
- return await self.async_step_discovery(dataclasses.asdict(discovery_info))
+ await self.async_set_unique_id(self._domain, raise_on_progress=False)
+ return await self.async_step_discovery({})
diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py
index 98dc8b8b752..a9a76b3b4d0 100644
--- a/homeassistant/components/sonos/entity.py
+++ b/homeassistant/components/sonos/entity.py
@@ -8,7 +8,7 @@ import logging
from soco.core import SoCo
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index 76a7d0bfa91..bfdf0da9dbb 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
"loggers": ["soco"],
- "requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"],
+ "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py
index e018c06e050..f024c4ef4f7 100644
--- a/homeassistant/components/sony_projector/switch.py
+++ b/homeassistant/components/sony_projector/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py
index c35c1e6f9c3..49750bc9baf 100644
--- a/homeassistant/components/soundtouch/__init__.py
+++ b/homeassistant/components/soundtouch/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py
index af45b8f6bdc..f30065d1157 100644
--- a/homeassistant/components/soundtouch/config_flow.py
+++ b/homeassistant/components/soundtouch/config_flow.py
@@ -6,10 +6,10 @@ from libsoundtouch import soundtouch_device
from requests import RequestException
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json
index 9fc11f7788a..2544eeb14a9 100644
--- a/homeassistant/components/soundtouch/strings.json
+++ b/homeassistant/components/soundtouch/strings.json
@@ -27,8 +27,8 @@
"description": "Plays on all Bose SoundTouch devices.",
"fields": {
"master": {
- "name": "Master",
- "description": "Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices."
+ "name": "Leader",
+ "description": "The media player entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices."
}
}
},
@@ -37,40 +37,40 @@
"description": "Creates a SoundTouch multi-room zone.",
"fields": {
"master": {
- "name": "Master",
- "description": "Name of the master entity that will coordinate the multi-room zone. Platform dependent."
+ "name": "Leader",
+ "description": "The media player entity that will coordinate the multi-room zone. Platform dependent."
},
"slaves": {
- "name": "Slaves",
- "description": "Name of slaves entities to add to the new zone."
+ "name": "Follower",
+ "description": "The media player entities to add to the new zone."
}
}
},
"add_zone_slave": {
- "name": "Add zone slave",
- "description": "Adds a slave to a SoundTouch multi-room zone.",
+ "name": "Add zone follower",
+ "description": "Adds media players to a SoundTouch multi-room zone.",
"fields": {
"master": {
- "name": "Master",
- "description": "Name of the master entity that is coordinating the multi-room zone. Platform dependent."
+ "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]",
+ "description": "The media player entity that is coordinating the multi-room zone. Platform dependent."
},
"slaves": {
"name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]",
- "description": "Name of slaves entities to add to the existing zone."
+ "description": "The media player entities to add to the existing zone."
}
}
},
"remove_zone_slave": {
- "name": "Remove zone slave",
- "description": "Removes a slave from the SoundTouch multi-room zone.",
+ "name": "Remove zone follower",
+ "description": "Removes media players from a SoundTouch multi-room zone.",
"fields": {
"master": {
- "name": "Master",
+ "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]",
"description": "[%key:component::soundtouch::services::add_zone_slave::fields::master::description%]"
},
"slaves": {
"name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]",
- "description": "Name of slaves entities to remove from the existing zone."
+ "description": "The media player entities to remove from the existing zone."
}
}
}
diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py
index 90281fe311c..6ef643488ad 100644
--- a/homeassistant/components/spaceapi/__init__.py
+++ b/homeassistant/components/spaceapi/__init__.py
@@ -5,6 +5,7 @@ import math
import voluptuous as vol
+from homeassistant import core as ha
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -21,11 +22,10 @@ from homeassistant.const import (
CONF_STATE,
CONF_URL,
)
-import homeassistant.core as ha
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
ATTR_ADDRESS = "address"
ATTR_SPACEFED = "spacefed"
diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py
index 3d9467f2041..2fed542e382 100644
--- a/homeassistant/components/spc/__init__.py
+++ b/homeassistant/components/spc/__init__.py
@@ -9,8 +9,7 @@ import voluptuous as vol
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client, discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py
index 4294020eeee..6ef8fed78d6 100644
--- a/homeassistant/components/splunk/__init__.py
+++ b/homeassistant/components/splunk/__init__.py
@@ -19,9 +19,8 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state as state_helper
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py
index 37580ac432d..663b3f30caa 100644
--- a/homeassistant/components/spotify/__init__.py
+++ b/homeassistant/components/spotify/__init__.py
@@ -32,11 +32,11 @@ from .util import (
PLATFORMS = [Platform.MEDIA_PLAYER]
__all__ = [
- "async_browse_media",
"DOMAIN",
- "spotify_uri_from_media_browser_url",
+ "async_browse_media",
"is_spotify_media_type",
"resolve_spotify_media_type",
+ "spotify_uri_from_media_browser_url",
]
diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py
index 81cdfdfb3cf..458525dde28 100644
--- a/homeassistant/components/spotify/browse_media.py
+++ b/homeassistant/components/spotify/browse_media.py
@@ -14,7 +14,7 @@ from spotifyaio import (
SpotifyClient,
Track,
)
-from spotifyaio.models import ItemType, SimplifiedEpisode
+from spotifyaio.models import Episode, ItemType, SimplifiedEpisode
import yarl
from homeassistant.components.media_player import (
@@ -363,7 +363,7 @@ async def build_item_response( # noqa: C901
items.append(_get_track_item_payload(playlist_item.track))
elif playlist_item.track.type is ItemType.EPISODE:
if TYPE_CHECKING:
- assert isinstance(playlist_item.track, SimplifiedEpisode)
+ assert isinstance(playlist_item.track, Episode)
items.append(_get_episode_item_payload(playlist_item.track))
elif media_content_type == MediaType.ALBUM:
if album := await spotify.get_album(media_content_id):
diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py
index 099b1cb3ca8..8b8539d715a 100644
--- a/homeassistant/components/spotify/coordinator.py
+++ b/homeassistant/components/spotify/coordinator.py
@@ -18,7 +18,7 @@ from spotifyaio import (
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 homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -31,6 +31,9 @@ _LOGGER = logging.getLogger(__name__)
type SpotifyConfigEntry = ConfigEntry[SpotifyData]
+UPDATE_INTERVAL = timedelta(seconds=30)
+
+
@dataclass
class SpotifyCoordinatorData:
"""Class to hold Spotify data."""
@@ -59,7 +62,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
hass,
_LOGGER,
name=DOMAIN,
- update_interval=timedelta(seconds=30),
+ update_interval=UPDATE_INTERVAL,
)
self.client = client
self._playlist: Playlist | None = None
@@ -73,6 +76,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
raise UpdateFailed("Error communicating with Spotify API") from err
async def _async_update_data(self) -> SpotifyCoordinatorData:
+ self.update_interval = UPDATE_INTERVAL
try:
current = await self.client.get_playback()
except SpotifyConnectionError as err:
@@ -120,6 +124,13 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
)
self._playlist = None
self._checked_playlist_id = None
+ if current.is_playing and current.progress_ms is not None:
+ assert current.item is not None
+ time_left = timedelta(
+ milliseconds=current.item.duration_ms - current.progress_ms
+ )
+ if time_left < UPDATE_INTERVAL:
+ self.update_interval = time_left + timedelta(seconds=1)
return SpotifyCoordinatorData(
current_playback=current,
position_updated_at=position_updated_at,
diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py
index 71e3671ce96..1b9e8502209 100644
--- a/homeassistant/components/sql/__init__.py
+++ b/homeassistant/components/sql/__init__.py
@@ -24,8 +24,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
CONF_PICTURE,
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index 01c95d6c5e4..0094770d53b 100644
--- a/homeassistant/components/sql/manifest.json
+++ b/homeassistant/components/sql/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
- "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"]
+ "requirements": ["SQLAlchemy==2.0.37", "sqlparse==0.5.0"]
}
diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py
index f466f3bcb62..f94ea118c6a 100644
--- a/homeassistant/components/squeezebox/__init__.py
+++ b/homeassistant/components/squeezebox/__init__.py
@@ -105,9 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
lms.name = (
(STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME])
and status[STATUS_QUERY_LIBRARYNAME]
- or host
- )
- version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None
+ ) or host
+ version = (STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION]) or None
# mac can be missing
mac_connect = (
{(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))}
diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py
index c372c7262d4..97eb848c21c 100644
--- a/homeassistant/components/squeezebox/config_flow.py
+++ b/homeassistant/components/squeezebox/config_flow.py
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any
from pysqueezebox import Server, async_discover
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
@@ -18,6 +17,7 @@ from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN
@@ -200,7 +200,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_edit()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery of a Squeezebox player."""
_LOGGER.debug(
diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json
index aa595340d56..09eaa4026f4 100644
--- a/homeassistant/components/squeezebox/manifest.json
+++ b/homeassistant/components/squeezebox/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/squeezebox",
"iot_class": "local_polling",
"loggers": ["pysqueezebox"],
- "requirements": ["pysqueezebox==0.10.0"]
+ "requirements": ["pysqueezebox==0.11.1"]
}
diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json
index 406c7243a1a..bce71ddb5f2 100644
--- a/homeassistant/components/squeezebox/strings.json
+++ b/homeassistant/components/squeezebox/strings.json
@@ -43,13 +43,13 @@
},
"parameters": {
"name": "Parameters",
- "description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).\n."
+ "description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation)."
}
}
},
"call_query": {
"name": "Call query",
- "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.\n.",
+ "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.",
"fields": {
"command": {
"name": "Command",
diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py
index ccd69961975..c5fb349ddbb 100644
--- a/homeassistant/components/ssdp/__init__.py
+++ b/homeassistant/components/ssdp/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine, Mapping
-from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum
from functools import partial
@@ -44,13 +43,36 @@ from homeassistant.const import (
__version__ as current_version,
)
from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback
-from homeassistant.data_entry_flow import BaseServiceInfo
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstant,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from homeassistant.helpers.network import NoURLAvailableError, get_url
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_NT as _ATTR_NT,
+ ATTR_ST as _ATTR_ST,
+ ATTR_UPNP_DEVICE_TYPE as _ATTR_UPNP_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME as _ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MANUFACTURER as _ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL,
+ ATTR_UPNP_MODEL_DESCRIPTION as _ATTR_UPNP_MODEL_DESCRIPTION,
+ ATTR_UPNP_MODEL_NAME as _ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_MODEL_NUMBER as _ATTR_UPNP_MODEL_NUMBER,
+ ATTR_UPNP_MODEL_URL as _ATTR_UPNP_MODEL_URL,
+ ATTR_UPNP_PRESENTATION_URL as _ATTR_UPNP_PRESENTATION_URL,
+ ATTR_UPNP_SERIAL as _ATTR_UPNP_SERIAL,
+ ATTR_UPNP_SERVICE_LIST as _ATTR_UPNP_SERVICE_LIST,
+ ATTR_UPNP_UDN as _ATTR_UPNP_UDN,
+ ATTR_UPNP_UPC as _ATTR_UPNP_UPC,
+ SsdpServiceInfo as _SsdpServiceInfo,
+)
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_ssdp, bind_hass
@@ -77,30 +99,90 @@ ATTR_SSDP_SERVER = "ssdp_server"
ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG"
ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG"
# Attributes for accessing info from retrieved UPnP device description
-ATTR_ST = "st"
-ATTR_NT = "nt"
-ATTR_UPNP_DEVICE_TYPE = "deviceType"
-ATTR_UPNP_FRIENDLY_NAME = "friendlyName"
-ATTR_UPNP_MANUFACTURER = "manufacturer"
-ATTR_UPNP_MANUFACTURER_URL = "manufacturerURL"
-ATTR_UPNP_MODEL_DESCRIPTION = "modelDescription"
-ATTR_UPNP_MODEL_NAME = "modelName"
-ATTR_UPNP_MODEL_NUMBER = "modelNumber"
-ATTR_UPNP_MODEL_URL = "modelURL"
-ATTR_UPNP_SERIAL = "serialNumber"
-ATTR_UPNP_SERVICE_LIST = "serviceList"
-ATTR_UPNP_UDN = "UDN"
-ATTR_UPNP_UPC = "UPC"
-ATTR_UPNP_PRESENTATION_URL = "presentationURL"
+_DEPRECATED_ATTR_ST = DeprecatedConstant(
+ _ATTR_ST,
+ "homeassistant.helpers.service_info.ssdp.ATTR_ST",
+ "2026.2",
+)
+_DEPRECATED_ATTR_NT = DeprecatedConstant(
+ _ATTR_NT,
+ "homeassistant.helpers.service_info.ssdp.ATTR_NT",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_DEVICE_TYPE = DeprecatedConstant(
+ _ATTR_UPNP_DEVICE_TYPE,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_FRIENDLY_NAME = DeprecatedConstant(
+ _ATTR_UPNP_FRIENDLY_NAME,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_MANUFACTURER = DeprecatedConstant(
+ _ATTR_UPNP_MANUFACTURER,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_MANUFACTURER_URL = DeprecatedConstant(
+ _ATTR_UPNP_MANUFACTURER_URL,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_MODEL_DESCRIPTION = DeprecatedConstant(
+ _ATTR_UPNP_MODEL_DESCRIPTION,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_MODEL_NAME = DeprecatedConstant(
+ _ATTR_UPNP_MODEL_NAME,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_MODEL_NUMBER = DeprecatedConstant(
+ _ATTR_UPNP_MODEL_NUMBER,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_MODEL_URL = DeprecatedConstant(
+ _ATTR_UPNP_MODEL_URL,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_SERIAL = DeprecatedConstant(
+ _ATTR_UPNP_SERIAL,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_SERVICE_LIST = DeprecatedConstant(
+ _ATTR_UPNP_SERVICE_LIST,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_UDN = DeprecatedConstant(
+ _ATTR_UPNP_UDN,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_UPC = DeprecatedConstant(
+ _ATTR_UPNP_UPC,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC",
+ "2026.2",
+)
+_DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant(
+ _ATTR_UPNP_PRESENTATION_URL,
+ "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL",
+ "2026.2",
+)
# Attributes for accessing info added by Home Assistant
ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains"
PRIMARY_MATCH_KEYS = [
- ATTR_UPNP_MANUFACTURER,
- ATTR_ST,
- ATTR_UPNP_DEVICE_TYPE,
- ATTR_NT,
- ATTR_UPNP_MANUFACTURER_URL,
+ _ATTR_UPNP_MANUFACTURER,
+ _ATTR_ST,
+ _ATTR_UPNP_DEVICE_TYPE,
+ _ATTR_NT,
+ _ATTR_UPNP_MANUFACTURER_URL,
]
_LOGGER = logging.getLogger(__name__)
@@ -108,27 +190,16 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
-
-@dataclass(slots=True)
-class SsdpServiceInfo(BaseServiceInfo):
- """Prepared info from ssdp/upnp entries."""
-
- ssdp_usn: str
- ssdp_st: str
- upnp: Mapping[str, Any]
- ssdp_location: str | None = None
- ssdp_nt: str | None = None
- ssdp_udn: str | None = None
- ssdp_ext: str | None = None
- ssdp_server: str | None = None
- ssdp_headers: Mapping[str, Any] = field(default_factory=dict)
- ssdp_all_locations: set[str] = field(default_factory=set)
- x_homeassistant_matching_domains: set[str] = field(default_factory=set)
+_DEPRECATED_SsdpServiceInfo = DeprecatedConstant(
+ _SsdpServiceInfo,
+ "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo",
+ "2026.2",
+)
SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE")
type SsdpHassJobCallback = HassJob[
- [SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None
+ [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None
]
SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = {
@@ -148,7 +219,9 @@ def _format_err(name: str, *args: Any) -> str:
@bind_hass
async def async_register_callback(
hass: HomeAssistant,
- callback: Callable[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None],
+ callback: Callable[
+ [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None
+ ],
match_dict: dict[str, str] | None = None,
) -> Callable[[], None]:
"""Register to receive a callback on ssdp broadcast.
@@ -169,7 +242,7 @@ async def async_register_callback(
@bind_hass
async def async_get_discovery_info_by_udn_st(
hass: HomeAssistant, udn: str, st: str
-) -> SsdpServiceInfo | None:
+) -> _SsdpServiceInfo | None:
"""Fetch the discovery info cache."""
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_get_discovery_info_by_udn_st(udn, st)
@@ -178,7 +251,7 @@ async def async_get_discovery_info_by_udn_st(
@bind_hass
async def async_get_discovery_info_by_st(
hass: HomeAssistant, st: str
-) -> list[SsdpServiceInfo]:
+) -> list[_SsdpServiceInfo]:
"""Fetch all the entries matching the st."""
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_get_discovery_info_by_st(st)
@@ -187,7 +260,7 @@ async def async_get_discovery_info_by_st(
@bind_hass
async def async_get_discovery_info_by_udn(
hass: HomeAssistant, udn: str
-) -> list[SsdpServiceInfo]:
+) -> list[_SsdpServiceInfo]:
"""Fetch all the entries matching the udn."""
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_get_discovery_info_by_udn(udn)
@@ -200,7 +273,7 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A
for source_ip in await network.async_get_enabled_source_ips(hass)
if not source_ip.is_loopback
and not source_ip.is_global
- and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4)
+ and ((source_ip.version == 6 and source_ip.scope_id) or source_ip.version == 4)
}
@@ -227,7 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
def _async_process_callbacks(
hass: HomeAssistant,
callbacks: list[SsdpHassJobCallback],
- discovery_info: SsdpServiceInfo,
+ discovery_info: _SsdpServiceInfo,
ssdp_change: SsdpChange,
) -> None:
for callback in callbacks:
@@ -562,11 +635,11 @@ class Scanner:
)
def _async_dismiss_discoveries(
- self, byebye_discovery_info: SsdpServiceInfo
+ self, byebye_discovery_info: _SsdpServiceInfo
) -> None:
"""Dismiss all discoveries for the given address."""
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type(
- SsdpServiceInfo,
+ _SsdpServiceInfo,
lambda service_info: bool(
service_info.ssdp_st == byebye_discovery_info.ssdp_st
and service_info.ssdp_location == byebye_discovery_info.ssdp_location
@@ -589,7 +662,7 @@ class Scanner:
async def _async_headers_to_discovery_info(
self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict
- ) -> SsdpServiceInfo:
+ ) -> _SsdpServiceInfo:
"""Combine the headers and description into discovery_info.
Building this is a bit expensive so we only do it on demand.
@@ -602,7 +675,7 @@ class Scanner:
async def async_get_discovery_info_by_udn_st(
self, udn: str, st: str
- ) -> SsdpServiceInfo | None:
+ ) -> _SsdpServiceInfo | None:
"""Return discovery_info for a udn and st."""
for ssdp_device in self._ssdp_devices:
if ssdp_device.udn == udn:
@@ -612,7 +685,7 @@ class Scanner:
)
return None
- async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]:
+ async def async_get_discovery_info_by_st(self, st: str) -> list[_SsdpServiceInfo]:
"""Return matching discovery_infos for a st."""
return [
await self._async_headers_to_discovery_info(ssdp_device, headers)
@@ -620,7 +693,7 @@ class Scanner:
if (headers := ssdp_device.combined_headers(st))
]
- async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]:
+ async def async_get_discovery_info_by_udn(self, udn: str) -> list[_SsdpServiceInfo]:
"""Return matching discovery_infos for a udn."""
return [
await self._async_headers_to_discovery_info(ssdp_device, headers)
@@ -665,7 +738,7 @@ def discovery_info_from_headers_and_description(
ssdp_device: SsdpDevice,
combined_headers: CaseInsensitiveDict,
info_desc: Mapping[str, Any],
-) -> SsdpServiceInfo:
+) -> _SsdpServiceInfo:
"""Convert headers and description to discovery_info."""
ssdp_usn = combined_headers["usn"]
ssdp_st = combined_headers.get_lower("st")
@@ -681,11 +754,11 @@ def discovery_info_from_headers_and_description(
ssdp_st = combined_headers["nt"]
# Ensure UPnP "udn" is set
- if ATTR_UPNP_UDN not in upnp_info:
+ if _ATTR_UPNP_UDN not in upnp_info:
if udn := _udn_from_usn(ssdp_usn):
- upnp_info[ATTR_UPNP_UDN] = udn
+ upnp_info[_ATTR_UPNP_UDN] = udn
- return SsdpServiceInfo(
+ return _SsdpServiceInfo(
ssdp_usn=ssdp_usn,
ssdp_st=ssdp_st,
ssdp_ext=combined_headers.get_lower("ext"),
@@ -887,3 +960,11 @@ class Server:
"""Stop UPnP/SSDP servers."""
for server in self._upnp_servers:
await server.async_stop()
+
+
+# These can be removed if no deprecated constant are in this module anymore
+__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
+__dir__ = partial(
+ dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
+)
+__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json
index 2632e37aa98..6e1fba8c3a3 100644
--- a/homeassistant/components/ssdp/manifest.json
+++ b/homeassistant/components/ssdp/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"quality_scale": "internal",
- "requirements": ["async-upnp-client==0.42.0"]
+ "requirements": ["async-upnp-client==0.43.0"]
}
diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py
index 4b1425ae7d9..0fb5a367148 100644
--- a/homeassistant/components/starline/account.py
+++ b/homeassistant/components/starline/account.py
@@ -180,6 +180,7 @@ class StarlineAccount:
"online": device.online,
}
+ # Deprecated and should be removed in 2025.8
@staticmethod
def engine_attrs(device: StarlineDevice) -> dict[str, Any]:
"""Attributes for engine switch."""
diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py
index 69f0ae06d02..ac1ad4f2b6e 100644
--- a/homeassistant/components/starline/binary_sensor.py
+++ b/homeassistant/components/starline/binary_sensor.py
@@ -43,8 +43,13 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
),
BinarySensorEntityDescription(
key="run",
- translation_key="is_running",
- device_class=BinarySensorDeviceClass.RUNNING,
+ translation_key="ignition",
+ entity_registry_enabled_default=False,
+ ),
+ BinarySensorEntityDescription(
+ key="r_start",
+ translation_key="autostart",
+ entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="hfree",
diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json
index d7d20ae03bd..07713a0cdfe 100644
--- a/homeassistant/components/starline/icons.json
+++ b/homeassistant/components/starline/icons.json
@@ -13,8 +13,11 @@
"moving_ban": {
"default": "mdi:car-off"
},
- "is_running": {
- "default": "mdi:speedometer"
+ "ignition": {
+ "default": "mdi:key-variant"
+ },
+ "autostart": {
+ "default": "mdi:auto-mode"
}
},
"button": {
diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json
index 0a30ea5b5be..b3ce755778e 100644
--- a/homeassistant/components/starline/strings.json
+++ b/homeassistant/components/starline/strings.json
@@ -33,7 +33,7 @@
}
},
"error": {
- "error_auth_app": "Incorrect application id or secret",
+ "error_auth_app": "Incorrect application ID or secret",
"error_auth_user": "Incorrect username or password",
"error_auth_mfa": "Incorrect code"
}
@@ -64,8 +64,11 @@
"moving_ban": {
"name": "Moving ban"
},
- "is_running": {
- "name": "Running"
+ "ignition": {
+ "name": "Ignition"
+ },
+ "autostart": {
+ "name": "Autostart"
}
},
"device_tracker": {
@@ -136,11 +139,11 @@
"services": {
"update_state": {
"name": "Update state",
- "description": "Fetches the last state of the devices from the StarLine server.\n."
+ "description": "Fetches the last state of the devices from the StarLine server."
},
"set_scan_interval": {
"name": "Set scan interval",
- "description": "Sets update frequency.",
+ "description": "Sets the update frequency for entities.",
"fields": {
"scan_interval": {
"name": "Scan interval",
@@ -150,7 +153,7 @@
},
"set_scan_obd_interval": {
"name": "Set scan OBD interval",
- "description": "Sets OBD info update frequency.",
+ "description": "Sets the update frequency for OBD information.",
"fields": {
"scan_interval": {
"name": "Scan interval",
diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py
index 05193d98c8a..eb71f0b73b5 100644
--- a/homeassistant/components/starline/switch.py
+++ b/homeassistant/components/starline/switch.py
@@ -72,6 +72,7 @@ class StarlineSwitch(StarlineEntity, SwitchEntity):
def extra_state_attributes(self):
"""Return the state attributes of the switch."""
if self._key == "ign":
+ # Deprecated and should be removed in 2025.8
return self._account.engine_attrs(self._device)
return None
diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py
index 282323d8b7b..063919179ac 100644
--- a/homeassistant/components/starlingbank/sensor.py
+++ b/homeassistant/components/starlingbank/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py
index 89d03a4fadc..6fcfd8e0bfe 100644
--- a/homeassistant/components/starlink/coordinator.py
+++ b/homeassistant/components/starlink/coordinator.py
@@ -52,6 +52,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
def __init__(self, hass: HomeAssistant, name: str, url: str) -> None:
"""Initialize an UpdateCoordinator for a group of sensors."""
self.channel_context = ChannelContext(target=url)
+ self.history_stats_start = None
self.timezone = ZoneInfo(hass.config.time_zone)
super().__init__(
hass,
@@ -67,7 +68,18 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
location = location_data(context)
sleep = get_sleep_config(context)
status, obstruction, alert = status_data(context)
- usage, consumption = history_stats(parse_samples=-1, context=context)[-2:]
+ index, _, _, _, _, usage, consumption, *_ = history_stats(
+ parse_samples=-1, start=self.history_stats_start, context=context
+ )
+ self.history_stats_start = index["end_counter"]
+ if self.data:
+ if index["samples"] > 0:
+ usage["download_usage"] += self.data.usage["download_usage"]
+ usage["upload_usage"] += self.data.usage["upload_usage"]
+ consumption["total_energy"] += self.data.consumption["total_energy"]
+ else:
+ usage = self.data.usage
+ consumption = self.data.consumption
return StarlinkData(
location, sleep, status, obstruction, alert, usage, consumption
)
diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py
index 5fc4872a754..62e02426fcb 100644
--- a/homeassistant/components/startca/sensor.py
+++ b/homeassistant/components/startca/sensor.py
@@ -25,8 +25,8 @@ from homeassistant.const import (
UnitOfInformation,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py
index 50b74b20028..4e8e5b7f942 100644
--- a/homeassistant/components/statsd/__init__.py
+++ b/homeassistant/components/statsd/__init__.py
@@ -7,8 +7,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py
index f22eafc6afd..cadcba118a1 100644
--- a/homeassistant/components/steamist/config_flow.py
+++ b/homeassistant/components/steamist/config_flow.py
@@ -9,12 +9,12 @@ from aiosteamist import Steamist
from discovery30303 import Device30303, normalize_mac
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import CONNECTION_EXCEPTIONS, DISCOVER_SCAN_TIMEOUT, DOMAIN
@@ -41,7 +41,7 @@ class SteamistConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device: Device30303 | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
self._discovered_device = Device30303(
diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py
index a5e92312f3d..94a3bd1058b 100644
--- a/homeassistant/components/stiebel_eltron/__init__.py
+++ b/homeassistant/components/stiebel_eltron/__init__.py
@@ -3,13 +3,13 @@
from datetime import timedelta
import logging
+from pymodbus.client import ModbusTcpClient
from pystiebeleltron import pystiebeleltron
import voluptuous as vol
from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
@@ -55,13 +55,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class StiebelEltronData:
"""Get the latest data and update the states."""
- def __init__(self, name, modbus_client):
+ def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None:
"""Init the STIEBEL ELTRON data object."""
self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
+ def update(self) -> None:
"""Update unit data."""
if not self.api.update():
_LOGGER.warning("Modbus read failed")
diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py
index 676f613f382..4d302a0f70d 100644
--- a/homeassistant/components/stiebel_eltron/climate.py
+++ b/homeassistant/components/stiebel_eltron/climate.py
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DOMAIN as STE_DOMAIN
+from . import DOMAIN as STE_DOMAIN, StiebelEltronData
DEPENDENCIES = ["stiebel_eltron"]
@@ -81,15 +81,15 @@ class StiebelEltron(ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- def __init__(self, name, ste_data):
+ def __init__(self, name: str, ste_data: StiebelEltronData) -> None:
"""Initialize the unit."""
self._name = name
- self._target_temperature = None
- self._current_temperature = None
- self._current_humidity = None
- self._operation = None
- self._filter_alarm = None
- self._force_update = False
+ self._target_temperature: float | int | None = None
+ self._current_temperature: float | int | None = None
+ self._current_humidity: float | int | None = None
+ self._operation: str | None = None
+ self._filter_alarm: bool | None = None
+ self._force_update: bool = False
self._ste_data = ste_data
def update(self) -> None:
@@ -108,59 +108,59 @@ class StiebelEltron(ClimateEntity):
)
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, bool | None]:
"""Return device specific state attributes."""
return {"filter_alarm": self._filter_alarm}
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the climate device."""
return self._name
# Handle ClimateEntityFeature.TARGET_TEMPERATURE
@property
- def current_temperature(self):
+ def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._current_temperature
@property
- def target_temperature(self):
+ def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._target_temperature
@property
- def target_temperature_step(self):
+ def target_temperature_step(self) -> float:
"""Return the supported step of target temperature."""
return 0.1
@property
- def min_temp(self):
+ def min_temp(self) -> float:
"""Return the minimum temperature."""
return 10.0
@property
- def max_temp(self):
+ def max_temp(self) -> float:
"""Return the maximum temperature."""
return 30.0
@property
- def current_humidity(self):
+ def current_humidity(self) -> float | None:
"""Return the current humidity."""
return float(f"{self._current_humidity:.1f}")
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation ie. heat, cool, idle."""
- return STE_TO_HA_HVAC.get(self._operation)
+ return STE_TO_HA_HVAC.get(self._operation) if self._operation else None
@property
- def preset_mode(self):
+ def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
- return STE_TO_HA_PRESET.get(self._operation)
+ return STE_TO_HA_PRESET.get(self._operation) if self._operation else None
@property
- def preset_modes(self):
+ def preset_modes(self) -> list[str]:
"""Return a list of available preset modes."""
return SUPPORT_PRESET
diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py
index 1471db890d7..8fa4c69ac5a 100644
--- a/homeassistant/components/stream/__init__.py
+++ b/homeassistant/components/stream/__init__.py
@@ -20,7 +20,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Mapping
import copy
-from enum import IntEnum
import logging
import secrets
import threading
@@ -34,19 +33,19 @@ from yarl import URL
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util.async_ import create_eager_task
from .const import (
ATTR_ENDPOINTS,
+ ATTR_PREFER_TCP,
ATTR_SETTINGS,
ATTR_STREAMS,
CONF_EXTRA_PART_WAIT_TIME,
CONF_LL_HLS,
CONF_PART_DURATION,
- CONF_PREFER_TCP,
CONF_RTSP_TRANSPORT,
CONF_SEGMENT_DURATION,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
@@ -62,6 +61,7 @@ from .const import (
SOURCE_TIMEOUT,
STREAM_RESTART_INCREMENT,
STREAM_RESTART_RESET_TIME,
+ StreamClientError,
)
from .core import (
PROVIDERS,
@@ -73,11 +73,10 @@ from .core import (
StreamSettings,
)
from .diagnostics import Diagnostics
+from .exceptions import StreamOpenClientError, StreamWorkerError
from .hls import HlsStreamOutput, async_setup_hls
if TYPE_CHECKING:
- from av.container import InputContainer, OutputContainer
-
from homeassistant.components.camera import DynamicStreamSettings
__all__ = [
@@ -91,99 +90,16 @@ __all__ = [
"OUTPUT_FORMATS",
"RTSP_TRANSPORTS",
"SOURCE_TIMEOUT",
- "Stream",
- "create_stream",
"Orientation",
+ "Stream",
+ "StreamClientError",
+ "StreamOpenClientError",
+ "create_stream",
]
_LOGGER = logging.getLogger(__name__)
-class StreamClientError(IntEnum):
- """Enum for stream client errors."""
-
- BadRequest = 400
- Unauthorized = 401
- Forbidden = 403
- NotFound = 404
- Other = 4
-
-
-class StreamOpenClientError(HomeAssistantError):
- """Raised when client error received when trying to open a stream.
-
- :param stream_client_error: The type of client error
- """
-
- def __init__(
- self, *args: Any, stream_client_error: StreamClientError, **kwargs: Any
- ) -> None:
- self.stream_client_error = stream_client_error
- super().__init__(*args, **kwargs)
-
-
-async def _async_try_open_stream(
- hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None
-) -> InputContainer | OutputContainer:
- """Try to open a stream.
-
- Will raise StreamOpenClientError if an http client error is encountered.
- """
- return await hass.loop.run_in_executor(None, _try_open_stream, source, pyav_options)
-
-
-def _try_open_stream(
- source: str, pyav_options: dict[str, str] | None = None
-) -> InputContainer | OutputContainer:
- """Try to open a stream.
-
- Will raise StreamOpenClientError if an http client error is encountered.
- """
- import av # pylint: disable=import-outside-toplevel
-
- if pyav_options is None:
- pyav_options = {}
-
- default_pyav_options = {
- "rtsp_flags": CONF_PREFER_TCP,
- "timeout": str(SOURCE_TIMEOUT),
- }
-
- pyav_options = {
- **default_pyav_options,
- **pyav_options,
- }
-
- try:
- container = av.open(source, options=pyav_options, timeout=5)
-
- except av.HTTPBadRequestError as ex:
- raise StreamOpenClientError(
- stream_client_error=StreamClientError.BadRequest
- ) from ex
-
- except av.HTTPUnauthorizedError as ex:
- raise StreamOpenClientError(
- stream_client_error=StreamClientError.Unauthorized
- ) from ex
-
- except av.HTTPForbiddenError as ex:
- raise StreamOpenClientError(
- stream_client_error=StreamClientError.Forbidden
- ) from ex
-
- except av.HTTPNotFoundError as ex:
- raise StreamOpenClientError(
- stream_client_error=StreamClientError.NotFound
- ) from ex
-
- except av.HTTPOtherClientError as ex:
- raise StreamOpenClientError(stream_client_error=StreamClientError.Other) from ex
-
- else:
- return container
-
-
async def async_check_stream_client_error(
hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None
) -> None:
@@ -192,18 +108,24 @@ async def async_check_stream_client_error(
Raise StreamOpenClientError if an http client error is encountered.
"""
await hass.loop.run_in_executor(
- None, _check_stream_client_error, source, pyav_options
+ None, _check_stream_client_error, hass, source, pyav_options
)
def _check_stream_client_error(
- source: str, pyav_options: dict[str, str] | None = None
+ hass: HomeAssistant, source: str, options: dict[str, str] | None = None
) -> None:
"""Check if a stream can be successfully opened.
Raise StreamOpenClientError if an http client error is encountered.
"""
- _try_open_stream(source, pyav_options).close()
+ from .worker import try_open_stream # pylint: disable=import-outside-toplevel
+
+ pyav_options, _ = _convert_stream_options(hass, source, options or {})
+ try:
+ try_open_stream(source, pyav_options).close()
+ except StreamWorkerError as err:
+ raise StreamOpenClientError(str(err), err.error_code) from err
def redact_credentials(url: str) -> str:
@@ -219,6 +141,42 @@ def redact_credentials(url: str) -> str:
return str(yurl.update_query(redacted_query_params))
+def _convert_stream_options(
+ hass: HomeAssistant,
+ stream_source: str,
+ stream_options: Mapping[str, str | bool | float],
+) -> tuple[dict[str, str], StreamSettings]:
+ """Convert options from stream options into PyAV options and stream settings."""
+ if DOMAIN not in hass.data:
+ raise HomeAssistantError("Stream integration is not set up.")
+
+ stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS])
+ pyav_options: dict[str, str] = {}
+ try:
+ STREAM_OPTIONS_SCHEMA(stream_options)
+ except vol.Invalid as exc:
+ raise HomeAssistantError(f"Invalid stream options: {exc}") from exc
+
+ if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME):
+ stream_settings.hls_part_timeout += extra_wait_time
+ if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT):
+ assert isinstance(rtsp_transport, str)
+ # The PyAV options currently match the stream CONF constants, but this
+ # will not necessarily always be the case, so they are hard coded here
+ pyav_options["rtsp_transport"] = rtsp_transport
+ if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
+ pyav_options["use_wallclock_as_timestamps"] = "1"
+
+ # For RTSP streams, prefer TCP
+ if isinstance(stream_source, str) and stream_source[:7] == "rtsp://":
+ pyav_options = {
+ "rtsp_flags": ATTR_PREFER_TCP,
+ "stimeout": "5000000",
+ **pyav_options,
+ }
+ return pyav_options, stream_settings
+
+
def create_stream(
hass: HomeAssistant,
stream_source: str,
@@ -234,41 +192,13 @@ def create_stream(
The stream_label is a string used as an additional message in logging.
"""
- def convert_stream_options(
- hass: HomeAssistant, stream_options: Mapping[str, str | bool | float]
- ) -> tuple[dict[str, str], StreamSettings]:
- """Convert options from stream options into PyAV options and stream settings."""
- stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS])
- pyav_options: dict[str, str] = {}
- try:
- STREAM_OPTIONS_SCHEMA(stream_options)
- except vol.Invalid as exc:
- raise HomeAssistantError("Invalid stream options") from exc
-
- if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME):
- stream_settings.hls_part_timeout += extra_wait_time
- if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT):
- assert isinstance(rtsp_transport, str)
- # The PyAV options currently match the stream CONF constants, but this
- # will not necessarily always be the case, so they are hard coded here
- pyav_options["rtsp_transport"] = rtsp_transport
- if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
- pyav_options["use_wallclock_as_timestamps"] = "1"
-
- return pyav_options, stream_settings
-
if DOMAIN not in hass.config.components:
raise HomeAssistantError("Stream integration is not set up.")
# Convert extra stream options into PyAV options and stream settings
- pyav_options, stream_settings = convert_stream_options(hass, options)
- # For RTSP streams, prefer TCP
- if isinstance(stream_source, str) and stream_source[:7] == "rtsp://":
- pyav_options = {
- "rtsp_flags": "prefer_tcp",
- "stimeout": "5000000",
- **pyav_options,
- }
+ pyav_options, stream_settings = _convert_stream_options(
+ hass, stream_source, options
+ )
stream = Stream(
hass,
@@ -531,7 +461,7 @@ class Stream:
"""Handle consuming streams and restart keepalive streams."""
# Keep import here so that we can import stream integration without installing reqs
# pylint: disable-next=import-outside-toplevel
- from .worker import StreamState, StreamWorkerError, stream_worker
+ from .worker import StreamState, stream_worker
stream_state = StreamState(self.hass, self.outputs, self._diagnostics)
wait_timeout = 0
diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py
index 66455ffad1a..c81d2f6cb18 100644
--- a/homeassistant/components/stream/const.py
+++ b/homeassistant/components/stream/const.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from enum import IntEnum
from typing import Final
DOMAIN = "stream"
@@ -48,7 +49,7 @@ CONF_LL_HLS = "ll_hls"
CONF_PART_DURATION = "part_duration"
CONF_SEGMENT_DURATION = "segment_duration"
-CONF_PREFER_TCP = "prefer_tcp"
+ATTR_PREFER_TCP = "prefer_tcp"
CONF_RTSP_TRANSPORT = "rtsp_transport"
# The first dict entry below may be used as the default when populating options
RTSP_TRANSPORTS = {
@@ -59,3 +60,18 @@ RTSP_TRANSPORTS = {
}
CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps"
CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time"
+
+
+class StreamClientError(IntEnum):
+ """Enum for stream client errors.
+
+ These are errors that can be returned by the stream client when trying to
+ open a stream. The caller should not interpret the int values directly, but
+ should use the enum values instead.
+ """
+
+ BadRequest = 400
+ Unauthorized = 401
+ Forbidden = 403
+ NotFound = 404
+ Other = 4
diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py
index 4184b23b9a0..b804055a740 100644
--- a/homeassistant/components/stream/core.py
+++ b/homeassistant/components/stream/core.py
@@ -166,7 +166,7 @@ class Segment:
self.hls_playlist_parts.append(
f"#EXT-X-PART:DURATION={part.duration:.3f},URI="
f'"./segment/{self.sequence}.{part_num}.m4s"'
- f'{",INDEPENDENT=YES" if part.has_keyframe else ""}'
+ f"{',INDEPENDENT=YES' if part.has_keyframe else ''}"
)
if self.complete:
# Construct the final playlist_template. The placeholder will share a
diff --git a/homeassistant/components/stream/exceptions.py b/homeassistant/components/stream/exceptions.py
new file mode 100644
index 00000000000..364ef6f3a02
--- /dev/null
+++ b/homeassistant/components/stream/exceptions.py
@@ -0,0 +1,32 @@
+"""Stream component exceptions."""
+
+from homeassistant.exceptions import HomeAssistantError
+
+from .const import StreamClientError
+
+
+class StreamOpenClientError(HomeAssistantError):
+ """Raised when client error received when trying to open a stream.
+
+ :param stream_client_error: The type of client error
+ """
+
+ def __init__(self, message: str, error_code: StreamClientError) -> None:
+ """Initialize a stream open client error."""
+ super().__init__(message)
+ self.error_code = error_code
+
+
+class StreamWorkerError(Exception):
+ """An exception thrown while processing a stream."""
+
+ def __init__(
+ self, message: str, error_code: StreamClientError = StreamClientError.Other
+ ) -> None:
+ """Initialize a stream worker error."""
+ super().__init__(message)
+ self.error_code = error_code
+
+
+class StreamEndedError(StreamWorkerError):
+ """Raised when the stream is complete, exposed for facilitating testing."""
diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py
index 16694822b01..32845840f38 100644
--- a/homeassistant/components/stream/hls.py
+++ b/homeassistant/components/stream/hls.py
@@ -188,9 +188,13 @@ class HlsPlaylistView(StreamView):
if track.stream_settings.ll_hls:
playlist.extend(
[
- f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}",
- f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}",
- f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES",
+ "#EXT-X-PART-INF:PART-TARGET="
+ f"{track.stream_settings.part_target_duration:.3f}",
+ "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK="
+ f"{2 * track.stream_settings.part_target_duration:.3f}",
+ "#EXT-X-START:TIME-OFFSET=-"
+ f"{EXT_X_START_LL_HLS * track.stream_settings.part_target_duration:.3f}"
+ ",PRECISE=YES",
]
)
else:
@@ -203,7 +207,9 @@ class HlsPlaylistView(StreamView):
# which seems to take precedence for setting target delay. Yet it also
# doesn't seem to hurt, so we can stick with it for now.
playlist.append(
- f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES"
+ "#EXT-X-START:TIME-OFFSET=-"
+ f"{EXT_X_START_NON_LL_HLS * track.target_duration:.3f}"
+ ",PRECISE=YES"
)
last_stream_id = first_segment.stream_id
diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json
index b9368565e2f..a2fa18c4d98 100644
--- a/homeassistant/components/stream/manifest.json
+++ b/homeassistant/components/stream/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.0"]
+ "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.2"]
}
diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py
index 8c9bb1b8e9e..c196e57baa4 100644
--- a/homeassistant/components/stream/worker.py
+++ b/homeassistant/components/stream/worker.py
@@ -15,6 +15,7 @@ from typing import Any, Self, cast
import av
import av.audio
import av.container
+from av.container import InputContainer
import av.stream
from homeassistant.core import HomeAssistant
@@ -29,6 +30,7 @@ from .const import (
PACKETS_TO_WAIT_FOR_AUDIO,
SEGMENT_CONTAINER_FORMAT,
SOURCE_TIMEOUT,
+ StreamClientError,
)
from .core import (
STREAM_SETTINGS_NON_LL_HLS,
@@ -39,6 +41,7 @@ from .core import (
StreamSettings,
)
from .diagnostics import Diagnostics
+from .exceptions import StreamEndedError, StreamWorkerError
from .fmp4utils import read_init
from .hls import HlsStreamOutput
@@ -46,10 +49,6 @@ _LOGGER = logging.getLogger(__name__)
NEGATIVE_INF = float("-inf")
-class StreamWorkerError(Exception):
- """An exception thrown while processing a stream."""
-
-
def redact_av_error_string(err: av.FFmpegError) -> str:
"""Return an error string with credentials redacted from the url."""
parts = [str(err.type), err.strerror] # type: ignore[attr-defined]
@@ -58,10 +57,6 @@ def redact_av_error_string(err: av.FFmpegError) -> str:
return ", ".join(parts)
-class StreamEndedError(StreamWorkerError):
- """Raised when the stream is complete, exposed for facilitating testing."""
-
-
class StreamState:
"""Responsible for trakcing output and playback state for a stream.
@@ -465,7 +460,7 @@ class TimestampValidator:
if packet.dts is None:
if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable]
raise StreamWorkerError(
- f"No dts in {MAX_MISSING_DTS+1} consecutive packets"
+ f"No dts in {MAX_MISSING_DTS + 1} consecutive packets"
)
self._missing_dts += 1
return False
@@ -512,6 +507,47 @@ def get_audio_bitstream_filter(
return None
+def try_open_stream(
+ source: str,
+ pyav_options: dict[str, str],
+) -> InputContainer:
+ """Try to open a stream.
+
+ Will raise StreamOpenClientError if an http client error is encountered.
+ """
+
+ try:
+ return av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT)
+ except av.HTTPBadRequestError as err:
+ raise StreamWorkerError(
+ f"Bad Request Error opening stream ({redact_av_error_string(err)})",
+ error_code=StreamClientError.BadRequest,
+ ) from err
+
+ except av.HTTPUnauthorizedError as err:
+ raise StreamWorkerError(
+ f"Unauthorized error opening stream ({redact_av_error_string(err)})",
+ error_code=StreamClientError.Unauthorized,
+ ) from err
+
+ except av.HTTPForbiddenError as err:
+ raise StreamWorkerError(
+ f"Forbidden error opening stream ({redact_av_error_string(err)})",
+ error_code=StreamClientError.Forbidden,
+ ) from err
+
+ except av.HTTPNotFoundError as err:
+ raise StreamWorkerError(
+ f"Not Found error opening stream ({redact_av_error_string(err)})",
+ error_code=StreamClientError.NotFound,
+ ) from err
+
+ except av.FFmpegError as err:
+ raise StreamWorkerError(
+ f"Error opening stream ({redact_av_error_string(err)})"
+ ) from err
+
+
def stream_worker(
source: str,
pyav_options: dict[str, str],
@@ -526,12 +562,7 @@ def stream_worker(
# the stimeout option was renamed to timeout as of ffmpeg 5.0
pyav_options["timeout"] = pyav_options["stimeout"]
del pyav_options["stimeout"]
- try:
- container = av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT)
- except av.FFmpegError as err:
- raise StreamWorkerError(
- f"Error opening stream ({redact_av_error_string(err)})"
- ) from err
+ container = try_open_stream(source, pyav_options)
try:
video_stream = container.streams.video[0]
except (KeyError, IndexError) as ex:
diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py
index 5eeb40630f8..313fc1f24c5 100644
--- a/homeassistant/components/streamlabswater/__init__.py
+++ b/homeassistant/components/streamlabswater/__init__.py
@@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import StreamlabsCoordinator
diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py
index d3c85aba1e7..25ed29d3071 100644
--- a/homeassistant/components/stt/__init__.py
+++ b/homeassistant/components/stt/__init__.py
@@ -49,20 +49,20 @@ from .legacy import (
from .models import SpeechMetadata, SpeechResult
__all__ = [
- "async_get_provider",
- "async_get_speech_to_text_engine",
- "async_get_speech_to_text_entity",
+ "DOMAIN",
"AudioBitRates",
"AudioChannels",
"AudioCodecs",
"AudioFormats",
"AudioSampleRates",
- "DOMAIN",
"Provider",
- "SpeechToTextEntity",
"SpeechMetadata",
"SpeechResult",
"SpeechResultState",
+ "SpeechToTextEntity",
+ "async_get_provider",
+ "async_get_speech_to_text_engine",
+ "async_get_speech_to_text_entity",
]
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json
index 760e4ccd689..8c5dd605582 100644
--- a/homeassistant/components/subaru/manifest.json
+++ b/homeassistant/components/subaru/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/subaru",
"iot_class": "cloud_polling",
"loggers": ["stdiomask", "subarulink"],
- "requirements": ["subarulink==0.7.11"]
+ "requirements": ["subarulink==0.7.13"]
}
diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py
index cbaac912642..30f8c030c26 100644
--- a/homeassistant/components/suez_water/__init__.py
+++ b/homeassistant/components/suez_water/__init__.py
@@ -2,13 +2,18 @@
from __future__ import annotations
+import logging
+
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from .const import CONF_COUNTER_ID
from .coordinator import SuezWaterConfigEntry, SuezWaterCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool:
"""Set up Suez Water from a config entry."""
@@ -26,3 +31,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: SuezWaterConfigEntry
+) -> bool:
+ """Migrate old suez water config entry."""
+ _LOGGER.debug(
+ "Migrating configuration from version %s.%s",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ if config_entry.version > 2:
+ return False
+
+ if config_entry.version == 1:
+ # Migrate to version 2
+ counter_id = config_entry.data.get(CONF_COUNTER_ID)
+ unique_id = str(counter_id)
+
+ hass.config_entries.async_update_entry(
+ config_entry,
+ unique_id=unique_id,
+ version=2,
+ )
+
+ _LOGGER.debug(
+ "Migration to configuration version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py
index b24dc1815ee..fb8bc2988d6 100644
--- a/homeassistant/components/suez_water/config_flow.py
+++ b/homeassistant/components/suez_water/config_flow.py
@@ -55,16 +55,15 @@ async def validate_input(data: dict[str, Any]) -> None:
class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Suez Water."""
- VERSION = 1
+ VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the initial step."""
+ """Handle the initial setup step."""
errors: dict[str, str] = {}
+
if user_input is not None:
- await self.async_set_unique_id(user_input[CONF_USERNAME])
- self._abort_if_unique_id_configured()
try:
await validate_input(user_input)
except CannotConnect:
@@ -77,9 +76,10 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- return self.async_create_entry(
- title=user_input[CONF_USERNAME], data=user_input
- )
+ counter_id = str(user_input[CONF_COUNTER_ID])
+ await self.async_set_unique_id(counter_id)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=counter_id, data=user_input)
return self.async_show_form(
step_id="user",
@@ -98,4 +98,4 @@ class InvalidAuth(HomeAssistantError):
class CounterNotFound(HomeAssistantError):
- """Error to indicate we cannot automatically found the counter id."""
+ """Error to indicate we failed to automatically find the counter id."""
diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py
index aab1ba110b7..38f94b8937e 100644
--- a/homeassistant/components/suez_water/coordinator.py
+++ b/homeassistant/components/suez_water/coordinator.py
@@ -85,9 +85,6 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
price=(await self._suez_client.get_price()).price,
)
except PySuezError as err:
- _LOGGER.exception(err)
- raise UpdateFailed(
- f"Suez coordinator error communicating with API: {err}"
- ) from err
+ raise UpdateFailed(f"Suez data update failed: {err}") from err
_LOGGER.debug("Successfully fetched suez data")
return data
diff --git a/homeassistant/components/suez_water/quality_scale.yaml b/homeassistant/components/suez_water/quality_scale.yaml
index 399c0b73a5a..dae9002b7dd 100644
--- a/homeassistant/components/suez_water/quality_scale.yaml
+++ b/homeassistant/components/suez_water/quality_scale.yaml
@@ -26,8 +26,10 @@ rules:
brands: done
# Silver
- config-entry-unloading: done
- log-when-unavailable: todo
+ config-entry-unloading:
+ status: todo
+ comment: cleanly close session
+ log-when-unavailable: done
entity-unavailable: done
action-exceptions:
status: exempt
@@ -48,25 +50,35 @@ rules:
# Gold
entity-translations: done
entity-device-class: done
- devices: done
- entity-category: todo
- entity-disabled-by-default: todo
+ devices:
+ status: todo
+ comment: see https://github.com/home-assistant/core/pull/134027#discussion_r1898732463
+ entity-category:
+ status: done
+ comment: default class is fine
+ entity-disabled-by-default:
+ status: todo
+ comment: price can always be disabled and yesterday usage after https://github.com/home-assistant/core/pull/131166
discovery:
status: exempt
comment: api only, nothing on local network to discover services
stale-devices:
- status: exempt
- comment: one device only
+ status: todo
+ comment: see devices
diagnostics: todo
exception-translations: todo
- icon-translations: todo
- reconfiguration-flow: todo
- dynamic-devices:
+ icon-translations:
status: exempt
- comment: one device only
+ comment: no custom icons
+ reconfiguration-flow:
+ status: todo
+ comment: reconfigure every configurations input
+ dynamic-devices:
+ status: todo
+ comment: see devices
discovery-update-info:
status: exempt
- comment: fixed api
+ comment: devices are not network dependent and will not be updated during their lives
repair-issues:
status: exempt
comment: No repair issues to be raised
@@ -78,7 +90,7 @@ rules:
comment: make it clearer
docs-known-limitations: todo
docs-troubleshooting: todo
- docs-examples: done
+ docs-examples: todo
# Platinum
async-dependency: done
diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py
index 7724816d636..71498990b6f 100644
--- a/homeassistant/components/sun/trigger.py
+++ b/homeassistant/components/sun/trigger.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
SUN_EVENT_SUNRISE,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_sunrise, async_track_sunset
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py
index 24189fb7de0..c443e1e63df 100644
--- a/homeassistant/components/supervisord/sensor.py
+++ b/homeassistant/components/supervisord/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py
index 8f04b5b662e..62f9b4b232d 100644
--- a/homeassistant/components/supla/__init__.py
+++ b/homeassistant/components/supla/__init__.py
@@ -11,8 +11,8 @@ import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py
index b422e40ef2d..3acd768cb30 100644
--- a/homeassistant/components/surepetcare/binary_sensor.py
+++ b/homeassistant/components/surepetcare/binary_sensor.py
@@ -135,8 +135,8 @@ class DeviceConnectivity(SurePetcareBinarySensor):
self._attr_is_on = bool(state)
if state:
self._attr_extra_state_attributes = {
- "device_rssi": f'{state["signal"]["device_rssi"]:.2f}',
- "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}',
+ "device_rssi": f"{state['signal']['device_rssi']:.2f}",
+ "hub_rssi": f"{state['signal']['hub_rssi']:.2f}",
}
else:
self._attr_extra_state_attributes = {}
diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py
index 3d88182eaa4..897b440a934 100644
--- a/homeassistant/components/swiss_hydrological_data/sensor.py
+++ b/homeassistant/components/swiss_hydrological_data/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py
index 58d674f0c26..4dc6efc2e85 100644
--- a/homeassistant/components/swiss_public_transport/config_flow.py
+++ b/homeassistant/components/swiss_public_transport/config_flow.py
@@ -11,8 +11,8 @@ from opendata_transport.exceptions import (
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
DurationSelector,
SelectSelector,
diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py
index c4cf2390dd0..81322117a6f 100644
--- a/homeassistant/components/swiss_public_transport/coordinator.py
+++ b/homeassistant/components/swiss_public_transport/coordinator.py
@@ -15,7 +15,7 @@ from opendata_transport.exceptions import (
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 homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN
diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py
index 704479b77d6..e41901337f4 100644
--- a/homeassistant/components/swiss_public_transport/helper.py
+++ b/homeassistant/components/swiss_public_transport/helper.py
@@ -6,7 +6,7 @@ from typing import Any
from opendata_transport import OpendataTransport
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import (
CONF_DESTINATION,
diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json
index 06a640a06b2..45cf4713705 100644
--- a/homeassistant/components/swiss_public_transport/icons.json
+++ b/homeassistant/components/swiss_public_transport/icons.json
@@ -10,7 +10,7 @@
"departure2": {
"default": "mdi:bus-clock"
},
- "duration": {
+ "trip_duration": {
"default": "mdi:timeline-clock"
},
"transfers": {
diff --git a/homeassistant/components/swiss_public_transport/quality_scale.yaml b/homeassistant/components/swiss_public_transport/quality_scale.yaml
index 0329f9c8fab..75dc642d77f 100644
--- a/homeassistant/components/swiss_public_transport/quality_scale.yaml
+++ b/homeassistant/components/swiss_public_transport/quality_scale.yaml
@@ -9,12 +9,12 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
- config-flow: todo
+ config-flow: done
dependency-transparency: todo
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No events implemented
@@ -37,10 +37,10 @@ rules:
reauthentication-flow:
status: exempt
comment: No authentication needed
- parallel-updates: todo
+ parallel-updates: done
test-coverage: todo
integration-owner: done
- docs-installation-parameters: todo
+ docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: no options flow
diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py
index 452ec31972f..c8075a6746c 100644
--- a/homeassistant/components/swiss_public_transport/sensor.py
+++ b/homeassistant/components/swiss_public_transport/sensor.py
@@ -27,6 +27,8 @@ from .coordinator import (
SwissPublicTransportDataUpdateCoordinator,
)
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=90)
@@ -54,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = (
],
SwissPublicTransportSensorEntityDescription(
key="duration",
+ translation_key="trip_duration",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data_connection: data_connection["duration"],
),
SwissPublicTransportSensorEntityDescription(
diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json
index 91645b2fee4..1cdbd527467 100644
--- a/homeassistant/components/swiss_public_transport/strings.json
+++ b/homeassistant/components/swiss_public_transport/strings.json
@@ -2,7 +2,7 @@
"config": {
"error": {
"cannot_connect": "Cannot connect to server",
- "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid",
+ "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid",
"too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.",
"unknown": "An unknown error was raised by python-opendata-transport"
},
@@ -17,21 +17,27 @@
"data": {
"from": "Start station",
"to": "End station",
- "via": "List of up to 5 via stations",
+ "via": "Via stations",
"time_station": "Select the relevant station",
"time_mode": "Select a time mode"
},
"data_description": {
+ "from": "The station where the connection starts",
+ "to": "The station where the connection ends",
+ "via": "List of up to 5 stations the route must go through",
"time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.",
"time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)."
},
- "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.",
+ "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.",
"title": "Swiss Public Transport"
},
"time_fixed": {
"data": {
"time_fixed": "Time of day"
},
+ "data_description": {
+ "time_fixed": "The time of day for the connection"
+ },
"description": "Please select the relevant time for the connection (e.g. 7:12:00 AM every morning).",
"title": "Swiss Public Transport"
},
@@ -39,6 +45,9 @@
"data": {
"time_offset": "Time offset"
},
+ "data_description": {
+ "time_offset": "The time offset added to the earliest possible connection"
+ },
"description": "Please select the relevant offset to add to the earliest possible connection (e.g. add +00:05:00 offset, taking into account the time to walk to the station)",
"title": "Swiss Public Transport"
}
@@ -55,8 +64,8 @@
"departure2": {
"name": "Departure +2"
},
- "duration": {
- "name": "Duration"
+ "trip_duration": {
+ "name": "Trip duration"
},
"transfers": {
"name": "Transfers"
diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py
index 66537a4311e..842dc657817 100644
--- a/homeassistant/components/swisscom/device_tracker.py
+++ b/homeassistant/components/swisscom/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index 61ee2908009..3c173cf5b2a 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -6,7 +6,7 @@ from datetime import timedelta
from enum import StrEnum
import logging
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py
index 48d555e6616..276496ce614 100644
--- a/homeassistant/components/switch/light.py
+++ b/homeassistant/components/switch/light.py
@@ -21,8 +21,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py
index 758698a7d67..a2a3ecf0df9 100644
--- a/homeassistant/components/switchbee/__init__.py
+++ b/homeassistant/components/switchbee/__init__.py
@@ -13,9 +13,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
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.device_registry as dr
-import homeassistant.helpers.entity_registry as er
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
@@ -114,7 +113,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if match := re.match(
rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id
):
- entity_new_unique_id = f'{new_unique_id}-{match.group("id")}'
+ entity_new_unique_id = f"{new_unique_id}-{match.group('id')}"
_LOGGER.debug(
"Migrating entity %s from %s to new id %s",
entity_entry.entity_id,
diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py
index c8d3d58ee09..b2cd53398ab 100644
--- a/homeassistant/components/switchbee/config_flow.py
+++ b/homeassistant/components/switchbee/config_flow.py
@@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py
index fc2d9f491ac..04b4e20b7ce 100644
--- a/homeassistant/components/switchbot/config_flow.py
+++ b/homeassistant/components/switchbot/config_flow.py
@@ -67,7 +67,7 @@ def short_address(address: str) -> str:
def name_from_discovery(discovery: SwitchBotAdvertisement) -> str:
"""Get the name from a discovery."""
- return f'{discovery.data["modelFriendlyName"]} {short_address(discovery.address)}'
+ return f"{discovery.data['modelFriendlyName']} {short_address(discovery.address)}"
class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -272,7 +272,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
@callback
def _async_discover_devices(self) -> None:
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for connectable in (True, False):
for discovery_info in async_discovered_service_info(self.hass, connectable):
address = discovery_info.address
diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml
new file mode 100644
index 00000000000..3b8976aeb8e
--- /dev/null
+++ b/homeassistant/components/switchbot/quality_scale.yaml
@@ -0,0 +1,96 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: todo
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates:
+ status: todo
+ comment: |
+ set `PARALLEL_UPDATES` in lock.py
+ reauthentication-flow: todo
+ test-coverage:
+ status: todo
+ comment: |
+ Consider using snapshots for fixating all the entities a device creates.
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No network discovery.
+ discovery:
+ status: done
+ comment: |
+ Can be improved: Device type scan filtering is applied to only show devices that are actually supported.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Only one device per config entry. New devices are set up as new entries.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations:
+ status: todo
+ comment: |
+ Needs to provide translations for hub2 temperature entity
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: |
+ No custom icons.
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ No need for reconfiguration flow.
+ repair-issues:
+ status: exempt
+ comment: |
+ No repairs/issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json
index 2a5ddaa0cba..fe44bc39e62 100644
--- a/homeassistant/components/switchbot/strings.json
+++ b/homeassistant/components/switchbot/strings.json
@@ -4,7 +4,10 @@
"step": {
"user": {
"data": {
- "address": "Device address"
+ "address": "MAC address"
+ },
+ "data_description": {
+ "address": "The Bluetooth MAC address of your SwitchBot device"
}
},
"confirm": {
@@ -14,6 +17,9 @@
"description": "The {name} device requires a password",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "The password required for the Bot device access"
}
},
"encrypted_key": {
@@ -21,6 +27,10 @@
"data": {
"key_id": "Key ID",
"encryption_key": "Encryption key"
+ },
+ "data_description": {
+ "key_id": "The ID of the encryption key",
+ "encryption_key": "The encryption key for the device"
}
},
"encrypted_auth": {
@@ -28,6 +38,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The username of your SwitchBot account",
+ "password": "The password of your SwitchBot account"
}
},
"encrypted_choose_method": {
diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py
index 827dce550ef..d7812158260 100644
--- a/homeassistant/components/switchbot_cloud/__init__.py
+++ b/homeassistant/components/switchbot_cloud/__init__.py
@@ -8,7 +8,7 @@ from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotA
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
@@ -16,6 +16,7 @@ from .coordinator import SwitchBotCoordinator
_LOGGER = getLogger(__name__)
PLATFORMS: list[Platform] = [
+ Platform.BUTTON,
Platform.CLIMATE,
Platform.LOCK,
Platform.SENSOR,
@@ -28,6 +29,7 @@ PLATFORMS: list[Platform] = [
class SwitchbotDevices:
"""Switchbot devices data."""
+ buttons: list[Device] = field(default_factory=list)
climates: list[Remote] = field(default_factory=list)
switches: list[Device | Remote] = field(default_factory=list)
sensors: list[Device] = field(default_factory=list)
@@ -43,75 +45,110 @@ class SwitchbotCloudData:
devices: SwitchbotDevices
-@callback
-def prepare_device(
+async def coordinator_for_device(
hass: HomeAssistant,
api: SwitchBotAPI,
device: Device | Remote,
coordinators_by_id: dict[str, SwitchBotCoordinator],
-) -> tuple[Device | Remote, SwitchBotCoordinator]:
+) -> SwitchBotCoordinator:
"""Instantiate coordinator and adds to list for gathering."""
coordinator = coordinators_by_id.setdefault(
device.device_id, SwitchBotCoordinator(hass, api, device)
)
- return (device, coordinator)
+
+ if coordinator.data is None:
+ await coordinator.async_config_entry_first_refresh()
+
+ return coordinator
-@callback
-def make_device_data(
+async def make_switchbot_devices(
hass: HomeAssistant,
api: SwitchBotAPI,
devices: list[Device | Remote],
coordinators_by_id: dict[str, SwitchBotCoordinator],
) -> SwitchbotDevices:
- """Make device data."""
+ """Make SwitchBot devices."""
devices_data = SwitchbotDevices()
- for device in devices:
- if isinstance(device, Remote) and device.device_type.endswith(
- "Air Conditioner"
- ):
- devices_data.climates.append(
- prepare_device(hass, api, device, coordinators_by_id)
- )
- if (
- isinstance(device, Device)
- and (
- device.device_type.startswith("Plug")
- or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"]
- )
- ) or isinstance(device, Remote):
- devices_data.switches.append(
- prepare_device(hass, api, device, coordinators_by_id)
- )
- if isinstance(device, Device) and device.device_type in [
- "Meter",
- "MeterPlus",
- "WoIOSensor",
- "Hub 2",
- "MeterPro",
- "MeterPro(CO2)",
- "Relay Switch 1PM",
- ]:
- devices_data.sensors.append(
- prepare_device(hass, api, device, coordinators_by_id)
- )
- if isinstance(device, Device) and device.device_type in [
- "K10+",
- "K10+ Pro",
- "Robot Vacuum Cleaner S1",
- "Robot Vacuum Cleaner S1 Plus",
- ]:
- devices_data.vacuums.append(
- prepare_device(hass, api, device, coordinators_by_id)
- )
+ await gather(
+ *[
+ make_device_data(hass, api, device, devices_data, coordinators_by_id)
+ for device in devices
+ ]
+ )
- if isinstance(device, Device) and device.device_type.startswith("Smart Lock"):
- devices_data.locks.append(
- prepare_device(hass, api, device, coordinators_by_id)
- )
return devices_data
+async def make_device_data(
+ hass: HomeAssistant,
+ api: SwitchBotAPI,
+ device: Device | Remote,
+ devices_data: SwitchbotDevices,
+ coordinators_by_id: dict[str, SwitchBotCoordinator],
+) -> None:
+ """Make device data."""
+ if isinstance(device, Remote) and device.device_type.endswith("Air Conditioner"):
+ coordinator = await coordinator_for_device(
+ hass, api, device, coordinators_by_id
+ )
+ devices_data.climates.append((device, coordinator))
+ if (
+ isinstance(device, Device)
+ and (
+ device.device_type.startswith("Plug")
+ or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"]
+ )
+ ) or isinstance(device, Remote):
+ coordinator = await coordinator_for_device(
+ hass, api, device, coordinators_by_id
+ )
+ devices_data.switches.append((device, coordinator))
+
+ if isinstance(device, Device) and device.device_type in [
+ "Meter",
+ "MeterPlus",
+ "WoIOSensor",
+ "Hub 2",
+ "MeterPro",
+ "MeterPro(CO2)",
+ "Relay Switch 1PM",
+ "Plug Mini (US)",
+ "Plug Mini (JP)",
+ ]:
+ coordinator = await coordinator_for_device(
+ hass, api, device, coordinators_by_id
+ )
+ devices_data.sensors.append((device, coordinator))
+
+ if isinstance(device, Device) and device.device_type in [
+ "K10+",
+ "K10+ Pro",
+ "Robot Vacuum Cleaner S1",
+ "Robot Vacuum Cleaner S1 Plus",
+ ]:
+ coordinator = await coordinator_for_device(
+ hass, api, device, coordinators_by_id
+ )
+ devices_data.vacuums.append((device, coordinator))
+
+ if isinstance(device, Device) and device.device_type.startswith("Smart Lock"):
+ coordinator = await coordinator_for_device(
+ hass, api, device, coordinators_by_id
+ )
+ devices_data.locks.append((device, coordinator))
+
+ if isinstance(device, Device) and device.device_type in ["Bot"]:
+ coordinator = await coordinator_for_device(
+ hass, api, device, coordinators_by_id
+ )
+ if coordinator.data is not None:
+ if coordinator.data.get("deviceMode") == "pressMode":
+ devices_data.buttons.append((device, coordinator))
+ else:
+ devices_data.switches.append((device, coordinator))
+
+
async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool:
"""Set up SwitchBot via API from a config entry."""
token = config.data[CONF_API_TOKEN]
@@ -129,14 +166,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool:
raise ConfigEntryNotReady from ex
_LOGGER.debug("Devices: %s", devices)
coordinators_by_id: dict[str, SwitchBotCoordinator] = {}
+
+ switchbot_devices = await make_switchbot_devices(
+ hass, api, devices, coordinators_by_id
+ )
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData(
- api=api, devices=make_device_data(hass, api, devices, coordinators_by_id)
+ api=api, devices=switchbot_devices
)
await hass.config_entries.async_forward_entry_setups(config, PLATFORMS)
- await gather(
- *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()]
- )
return True
diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py
new file mode 100644
index 00000000000..a6eb1a134a5
--- /dev/null
+++ b/homeassistant/components/switchbot_cloud/button.py
@@ -0,0 +1,41 @@
+"""Support for the Switchbot Bot as a Button."""
+
+from typing import Any
+
+from switchbot_api import BotCommands
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import SwitchbotCloudData
+from .const import DOMAIN
+from .entity import SwitchBotCloudEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up SwitchBot Cloud entry."""
+ data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
+ async_add_entities(
+ SwitchBotCloudBot(data.api, device, coordinator)
+ for device, coordinator in data.devices.buttons
+ )
+
+
+class SwitchBotCloudBot(SwitchBotCloudEntity, ButtonEntity):
+ """Representation of a SwitchBot Bot."""
+
+ _attr_name = None
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+
+ async def async_press(self, **kwargs: Any) -> None:
+ """Bot press command."""
+ await self.send_api_command(BotCommands.PRESS)
diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py
index 4e05e9e9a1e..9e996649e8c 100644
--- a/homeassistant/components/switchbot_cloud/climate.py
+++ b/homeassistant/components/switchbot_cloud/climate.py
@@ -4,7 +4,7 @@ from typing import Any
from switchbot_api import AirConditionerCommands
-import homeassistant.components.climate as FanState
+from homeassistant.components import climate as FanState
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py
index f77adb7b192..74adcb049c1 100644
--- a/homeassistant/components/switchbot_cloud/entity.py
+++ b/homeassistant/components/switchbot_cloud/entity.py
@@ -4,6 +4,7 @@ from typing import Any
from switchbot_api import Commands, Device, Remote, SwitchBotAPI
+from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -48,3 +49,17 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]):
command_type,
parameters,
)
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._set_attributes()
+ super()._handle_coordinator_update()
+
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity is about to be added to hass."""
+ await super().async_added_to_hass()
+ self._set_attributes()
diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py
index 2fbd551b919..52f48c66d38 100644
--- a/homeassistant/components/switchbot_cloud/lock.py
+++ b/homeassistant/components/switchbot_cloud/lock.py
@@ -6,7 +6,7 @@ from switchbot_api import LockCommands
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SwitchbotCloudData
@@ -32,12 +32,10 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity):
_attr_name = None
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
if coord_data := self.coordinator.data:
self._attr_is_locked = coord_data["lockState"] == "locked"
- self.async_write_ha_state()
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py
index ae912e914ba..1f755c141a2 100644
--- a/homeassistant/components/switchbot_cloud/sensor.py
+++ b/homeassistant/components/switchbot_cloud/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
UnitOfPower,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SwitchbotCloudData
@@ -61,20 +61,27 @@ POWER_DESCRIPTION = SensorEntityDescription(
native_unit_of_measurement=UnitOfPower.WATT,
)
-VOLATGE_DESCRIPTION = SensorEntityDescription(
+VOLTAGE_DESCRIPTION = SensorEntityDescription(
key=SENSOR_TYPE_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
)
-CURRENT_DESCRIPTION = SensorEntityDescription(
+CURRENT_DESCRIPTION_IN_MA = SensorEntityDescription(
key=SENSOR_TYPE_CURRENT,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
)
+CURRENT_DESCRIPTION_IN_A = SensorEntityDescription(
+ key=SENSOR_TYPE_CURRENT,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+)
+
CO2_DESCRIPTION = SensorEntityDescription(
key=SENSOR_TYPE_CO2,
device_class=SensorDeviceClass.CO2,
@@ -100,8 +107,16 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
),
"Relay Switch 1PM": (
POWER_DESCRIPTION,
- VOLATGE_DESCRIPTION,
- CURRENT_DESCRIPTION,
+ VOLTAGE_DESCRIPTION,
+ CURRENT_DESCRIPTION_IN_MA,
+ ),
+ "Plug Mini (US)": (
+ VOLTAGE_DESCRIPTION,
+ CURRENT_DESCRIPTION_IN_A,
+ ),
+ "Plug Mini (JP)": (
+ VOLTAGE_DESCRIPTION,
+ CURRENT_DESCRIPTION_IN_A,
),
"Hub 2": (
TEMPERATURE_DESCRIPTION,
@@ -151,10 +166,8 @@ class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity):
self.entity_description = description
self._attr_unique_id = f"{device.device_id}_{description.key}"
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
if not self.coordinator.data:
return
self._attr_native_value = self.coordinator.data.get(self.entity_description.key)
- self.async_write_ha_state()
diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py
index 281ebb9322e..22d033625f9 100644
--- a/homeassistant/components/switchbot_cloud/switch.py
+++ b/homeassistant/components/switchbot_cloud/switch.py
@@ -46,21 +46,18 @@ class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
if not self.coordinator.data:
return
self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value
- self.async_write_ha_state()
class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch):
"""Representation of a SwitchBot switch provider by a remote."""
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch):
@@ -72,13 +69,11 @@ class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch):
class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch):
"""Representation of a SwitchBot relay switch."""
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
if not self.coordinator.data:
return
self._attr_is_on = self.coordinator.data.get("switchStatus") == 1
- self.async_write_ha_state()
@callback
@@ -95,4 +90,6 @@ def _async_make_entity(
"Relay Switch 1",
]:
return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator)
+ if "Bot" in device.device_type:
+ return SwitchBotCloudSwitch(api, device, coordinator)
raise NotImplementedError(f"Unsupported device type: {device.device_type}")
diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py
index 2d2a1783d73..84db7cfdbb8 100644
--- a/homeassistant/components/switchbot_cloud/vacuum.py
+++ b/homeassistant/components/switchbot_cloud/vacuum.py
@@ -99,9 +99,8 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
"""Start or resume the cleaning task."""
await self.send_api_command(VacuumCommands.START)
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
if not self.coordinator.data:
return
@@ -111,8 +110,6 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
switchbot_state = str(self.coordinator.data.get("workingStatus"))
self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state)
- self.async_write_ha_state()
-
@callback
def _async_make_entity(
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
index d0731c5ae3b..f96b10b4b6f 100644
--- a/homeassistant/components/switcher_kis/manifest.json
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/switcher_kis",
"iot_class": "local_push",
"loggers": ["aioswitcher"],
- "requirements": ["aioswitcher==5.1.0"],
+ "requirements": ["aioswitcher==6.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json
index 844cbb4ca98..e380711303d 100644
--- a/homeassistant/components/switcher_kis/strings.json
+++ b/homeassistant/components/switcher_kis/strings.json
@@ -63,6 +63,14 @@
"temperature": {
"name": "Current temperature"
}
+ },
+ "switch": {
+ "child_lock": {
+ "name": "Child lock"
+ },
+ "multi_child_lock": {
+ "name": "Child lock {cover_id}"
+ }
}
},
"services": {
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
index ba0a99b4089..7d3d71a0615 100644
--- a/homeassistant/components/switcher_kis/switch.py
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -4,14 +4,15 @@ from __future__ import annotations
from datetime import timedelta
import logging
-from typing import Any
+from typing import Any, cast
-from aioswitcher.api import Command
-from aioswitcher.device import DeviceCategory, DeviceState
+from aioswitcher.api import Command, ShutterChildLock
+from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter
import voluptuous as vol
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -32,6 +33,7 @@ _LOGGER = logging.getLogger(__name__)
API_CONTROL_DEVICE = "control_device"
API_SET_AUTO_SHUTDOWN = "set_auto_shutdown"
+API_SET_CHILD_LOCK = "set_shutter_child_lock"
SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = {
vol.Required(CONF_AUTO_OFF): cv.time_period_str,
@@ -67,10 +69,28 @@ async def async_setup_entry(
@callback
def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None:
"""Add switch from Switcher device."""
+ entities: list[SwitchEntity] = []
+
if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG:
- async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)])
+ entities.append(SwitcherPowerPlugSwitchEntity(coordinator))
elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER:
- async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)])
+ entities.append(SwitcherWaterHeaterSwitchEntity(coordinator))
+ elif coordinator.data.device_type.category in (
+ DeviceCategory.SHUTTER,
+ DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT,
+ DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT,
+ ):
+ number_of_covers = len(cast(SwitcherShutter, coordinator.data).position)
+ if number_of_covers == 1:
+ entities.append(
+ SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0)
+ )
+ else:
+ entities.extend(
+ SwitchereShutterChildLockMultiSwitchEntity(coordinator, i)
+ for i in range(number_of_covers)
+ )
+ async_add_entities(entities)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch)
@@ -154,3 +174,91 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity):
await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes)
self.control_result = True
self.async_write_ha_state()
+
+
+class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity):
+ """Representation of a Switcher shutter base switch entity."""
+
+ _attr_device_class = SwitchDeviceClass.SWITCH
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_icon = "mdi:lock-open"
+ _cover_id: int
+
+ def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.control_result: bool | None = None
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """When device updates, clear control result that overrides state."""
+ self.control_result = None
+ super()._handle_coordinator_update()
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ if self.control_result is not None:
+ return self.control_result
+
+ data = cast(SwitcherShutter, self.coordinator.data)
+ return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self._async_call_api(
+ API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id
+ )
+ self.control_result = True
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self._async_call_api(
+ API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id
+ )
+ self.control_result = False
+ self.async_write_ha_state()
+
+
+class SwitchereShutterChildLockSingleSwitchEntity(
+ SwitchereShutterChildLockBaseSwitchEntity
+):
+ """Representation of a Switcher runner child lock single switch entity."""
+
+ _attr_translation_key = "child_lock"
+
+ def __init__(
+ self,
+ coordinator: SwitcherDataUpdateCoordinator,
+ cover_id: int,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._cover_id = cover_id
+
+ self._attr_unique_id = (
+ f"{coordinator.device_id}-{coordinator.mac_address}-child_lock"
+ )
+
+
+class SwitchereShutterChildLockMultiSwitchEntity(
+ SwitchereShutterChildLockBaseSwitchEntity
+):
+ """Representation of a Switcher runner child lock multiple switch entity."""
+
+ _attr_translation_key = "multi_child_lock"
+
+ def __init__(
+ self,
+ coordinator: SwitcherDataUpdateCoordinator,
+ cover_id: int,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._cover_id = cover_id
+
+ self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)}
+ self._attr_unique_id = (
+ f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock"
+ )
diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py
index 8484eb5a2d1..0b449c65194 100644
--- a/homeassistant/components/switchmate/switch.py
+++ b/homeassistant/components/switchmate/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py
index 1fb155a5648..1407814f838 100644
--- a/homeassistant/components/syncthru/config_flow.py
+++ b/homeassistant/components/syncthru/config_flow.py
@@ -8,10 +8,15 @@ from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported
from url_normalize import url_normalize
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_PRESENTATION_URL,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN
@@ -33,15 +38,15 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_check_and_create("user", user_input)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle SSDP initiated flow."""
- await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN])
+ await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
self._abort_if_unique_id_configured()
self.url = url_normalize(
discovery_info.upnp.get(
- ssdp.ATTR_UPNP_PRESENTATION_URL,
+ ATTR_UPNP_PRESENTATION_URL,
f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/",
)
)
@@ -52,11 +57,11 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN):
# Update unique id of entry with the same URL
if not existing_entry.unique_id:
self.hass.config_entries.async_update_entry(
- existing_entry, unique_id=discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
+ existing_entry, unique_id=discovery_info.upnp[ATTR_UPNP_UDN]
)
return self.async_abort(reason="already_configured")
- self.name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "")
+ self.name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "")
if self.name:
# Remove trailing " (ip)" if present for consistency with user driven config
self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name)
diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py
index 38c302b7968..37ea3238a06 100644
--- a/homeassistant/components/synology_chat/notify.py
+++ b/homeassistant/components/synology_chat/notify.py
@@ -16,7 +16,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_RESOURCE, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
ATTR_FILE_URL = "file_url"
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index 3619619782e..0b8b8731f8f 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -11,12 +11,15 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .common import SynoApi, raise_config_entry_auth_error
from .const import (
+ CONF_BACKUP_PATH,
+ CONF_BACKUP_SHARE,
+ DATA_BACKUP_AGENT_LISTENERS,
DEFAULT_VERIFY_SSL,
DOMAIN,
EXCEPTION_DETAILS,
@@ -60,6 +63,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL}
)
+ if CONF_BACKUP_SHARE not in entry.options:
+ hass.config_entries.async_update_entry(
+ entry,
+ options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None},
+ )
# Continue setup
api = SynoApi(hass, entry)
@@ -118,6 +126,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
+ if entry.options[CONF_BACKUP_SHARE]:
+ _async_notify_backup_listeners_soon(hass)
+
return True
@@ -127,9 +138,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
await entry_data.api.async_unload()
hass.data[DOMAIN].pop(entry.unique_id)
+ _async_notify_backup_listeners_soon(hass)
return unload_ok
+def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+
+@callback
+def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
+ hass.loop.call_soon(_async_notify_backup_listeners, hass)
+
+
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py
new file mode 100644
index 00000000000..83c3455bdf1
--- /dev/null
+++ b/homeassistant/components/synology_dsm/backup.py
@@ -0,0 +1,268 @@
+"""Support for Synology DSM backup agents."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+import logging
+from typing import TYPE_CHECKING, Any
+
+from aiohttp import StreamReader
+from synology_dsm.api.file_station import SynoFileStation
+from synology_dsm.exceptions import SynologyDSMAPIErrorException
+
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ suggested_filename,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
+from homeassistant.helpers.json import json_dumps
+from homeassistant.util.json import JsonObjectType, json_loads_object
+
+from .const import (
+ CONF_BACKUP_PATH,
+ CONF_BACKUP_SHARE,
+ DATA_BACKUP_AGENT_LISTENERS,
+ DOMAIN,
+)
+from .models import SynologyDSMData
+
+LOGGER = logging.getLogger(__name__)
+
+
+def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
+ """Suggest filenames for the backup.
+
+ returns a tuple of tar_filename and meta_filename
+ """
+ base_name = suggested_filename(backup).rsplit(".", 1)[0]
+ return (f"{base_name}.tar", f"{base_name}_meta.json")
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+) -> list[BackupAgent]:
+ """Return a list of backup agents."""
+ if not (
+ entries := hass.config_entries.async_loaded_entries(DOMAIN)
+ ) or not hass.data.get(DOMAIN):
+ LOGGER.debug("No proper config entry found")
+ return []
+ syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN]
+ return [
+ SynologyDSMBackupAgent(hass, entry, entry.unique_id)
+ for entry in entries
+ if entry.unique_id is not None
+ and (syno_data := syno_datas.get(entry.unique_id))
+ and syno_data.api.file_station
+ and entry.options.get(CONF_BACKUP_PATH)
+ ]
+
+
+@callback
+def async_register_backup_agents_listener(
+ hass: HomeAssistant,
+ *,
+ listener: Callable[[], None],
+ **kwargs: Any,
+) -> Callable[[], None]:
+ """Register a listener to be called when agents are added or removed.
+
+ :return: A function to unregister the listener.
+ """
+ hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove the listener."""
+ hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
+ if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
+ del hass.data[DATA_BACKUP_AGENT_LISTENERS]
+
+ return remove_listener
+
+
+class SynologyDSMBackupAgent(BackupAgent):
+ """Synology DSM backup agent."""
+
+ domain = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None:
+ """Initialize the Synology DSM backup agent."""
+ super().__init__()
+ LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id)
+ self.name = entry.title
+ self.unique_id = unique_id
+ self.path = (
+ f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}"
+ )
+ syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ self.api = syno_data.api
+
+ @property
+ def _file_station(self) -> SynoFileStation:
+ if TYPE_CHECKING:
+ # we ensure that file_station exist already in async_get_backup_agents
+ assert self.api.file_station
+ return self.api.file_station
+
+ async def _async_suggested_filenames(
+ self,
+ backup_id: str,
+ ) -> tuple[str, str]:
+ """Suggest filenames for the backup.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ :return: A tuple of tar_filename and meta_filename
+ """
+ if (backup := await self.async_get_backup(backup_id)) is None:
+ raise BackupAgentError("Backup not found")
+ return suggested_filenames(backup)
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ :return: An async iterator that yields bytes.
+ """
+ (filename_tar, _) = await self._async_suggested_filenames(backup_id)
+
+ try:
+ resp = await self._file_station.download_file(
+ path=self.path,
+ filename=filename_tar,
+ )
+ except SynologyDSMAPIErrorException as err:
+ raise BackupAgentError("Failed to download backup") from err
+
+ if TYPE_CHECKING:
+ assert isinstance(resp, StreamReader)
+
+ return ChunkAsyncStreamIterator(resp)
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup.
+
+ :param open_stream: A function returning an async iterator that yields bytes.
+ :param backup: Metadata about the backup that should be uploaded.
+ """
+
+ (filename_tar, filename_meta) = suggested_filenames(backup)
+
+ # upload backup.tar file first
+ try:
+ await self._file_station.upload_file(
+ path=self.path,
+ filename=filename_tar,
+ source=await open_stream(),
+ create_parents=True,
+ )
+ except SynologyDSMAPIErrorException as err:
+ raise BackupAgentError("Failed to upload backup") from err
+
+ # upload backup_meta.json file when backup.tar was successful uploaded
+ try:
+ await self._file_station.upload_file(
+ path=self.path,
+ filename=filename_meta,
+ source=json_dumps(backup.as_dict()).encode(),
+ )
+ except SynologyDSMAPIErrorException as err:
+ raise BackupAgentError("Failed to upload backup") from err
+
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ """
+ try:
+ (filename_tar, filename_meta) = await self._async_suggested_filenames(
+ backup_id
+ )
+ except BackupAgentError:
+ # backup meta data could not be found, so we can't delete the backup
+ return
+
+ for filename in (filename_tar, filename_meta):
+ try:
+ await self._file_station.delete_file(path=self.path, filename=filename)
+ except SynologyDSMAPIErrorException as err:
+ err_args: dict = err.args[0]
+ if int(err_args.get("code", 0)) != 900 or (
+ (err_details := err_args.get("details")) is not None
+ and isinstance(err_details, list)
+ and isinstance(err_details[0], dict)
+ and int(err_details[0].get("code", 0))
+ != 408 # No such file or directory
+ ):
+ LOGGER.error("Failed to delete backup: %s", err)
+ raise BackupAgentError("Failed to delete backup") from err
+
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ return list((await self._async_list_backups(**kwargs)).values())
+
+ async def _async_list_backups(self, **kwargs: Any) -> dict[str, AgentBackup]:
+ """List backups."""
+
+ async def _download_meta_data(filename: str) -> JsonObjectType:
+ try:
+ resp = await self._file_station.download_file(
+ path=self.path, filename=filename
+ )
+ except SynologyDSMAPIErrorException as err:
+ raise BackupAgentError("Failed to download meta data") from err
+
+ if TYPE_CHECKING:
+ assert isinstance(resp, StreamReader)
+
+ try:
+ return json_loads_object(await resp.read())
+ except Exception as err:
+ raise BackupAgentError("Failed to read meta data") from err
+
+ try:
+ files = await self._file_station.get_files(path=self.path)
+ except SynologyDSMAPIErrorException as err:
+ raise BackupAgentError("Failed to list backups") from err
+
+ if TYPE_CHECKING:
+ assert files
+
+ backups: dict[str, AgentBackup] = {}
+ for file in files:
+ if file.name.endswith("_meta.json"):
+ try:
+ meta_data = await _download_meta_data(file.name)
+ except BackupAgentError as err:
+ LOGGER.error("Failed to download meta data: %s", err)
+ continue
+ agent_backup = AgentBackup.from_dict(meta_data)
+ backups[agent_backup.backup_id] = agent_backup
+ return backups
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ backups = await self._async_list_backups()
+ return backups.get(backup_id)
diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py
index 9a6284eff2b..dfc372e6bde 100644
--- a/homeassistant/components/synology_dsm/common.py
+++ b/homeassistant/components/synology_dsm/common.py
@@ -14,6 +14,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade
from synology_dsm.api.core.utilization import SynoCoreUtilization
from synology_dsm.api.dsm.information import SynoDSMInformation
from synology_dsm.api.dsm.network import SynoDSMNetwork
+from synology_dsm.api.file_station import SynoFileStation
from synology_dsm.api.photos import SynoPhotos
from synology_dsm.api.storage.storage import SynoStorage
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
@@ -62,11 +63,12 @@ class SynoApi:
self.config_url = f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"
# DSM APIs
+ self.file_station: SynoFileStation | None = None
self.information: SynoDSMInformation | None = None
self.network: SynoDSMNetwork | None = None
+ self.photos: SynoPhotos | None = None
self.security: SynoCoreSecurity | None = None
self.storage: SynoStorage | None = None
- self.photos: SynoPhotos | None = None
self.surveillance_station: SynoSurveillanceStation | None = None
self.system: SynoCoreSystem | None = None
self.upgrade: SynoCoreUpgrade | None = None
@@ -74,10 +76,11 @@ class SynoApi:
# Should we fetch them
self._fetching_entities: dict[str, set[str]] = {}
+ self._with_file_station = True
self._with_information = True
+ self._with_photos = True
self._with_security = True
self._with_storage = True
- self._with_photos = True
self._with_surveillance_station = True
self._with_system = True
self._with_upgrade = True
@@ -157,6 +160,26 @@ class SynoApi:
self.dsm.reset(SynoCoreUpgrade.API_KEY)
LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex)
+ # check if file station is used and permitted
+ self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY))
+ if self._with_file_station:
+ shares: list | None = None
+ with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
+ shares = await self.dsm.file.get_shared_folders(only_writable=True)
+ if not shares:
+ self._with_file_station = False
+ self.dsm.reset(SynoFileStation.API_KEY)
+ LOGGER.debug(
+ "File Station found, but disabled due to missing user"
+ " permissions or no writable shared folders available"
+ )
+
+ LOGGER.debug(
+ "State of File Station during setup of '%s': %s",
+ self._entry.unique_id,
+ self._with_file_station,
+ )
+
await self._fetch_device_configuration()
try:
@@ -225,6 +248,15 @@ class SynoApi:
self.dsm.reset(self.security)
self.security = None
+ if not self._with_file_station:
+ LOGGER.debug(
+ "Disable file station api from being updated or '%s'",
+ self._entry.unique_id,
+ )
+ if self.file_station:
+ self.dsm.reset(self.file_station)
+ self.file_station = None
+
if not self._with_photos:
LOGGER.debug(
"Disable photos api from being updated or '%s'", self._entry.unique_id
@@ -272,6 +304,12 @@ class SynoApi:
self.network = self.dsm.network
await self.network.update()
+ if self._with_file_station:
+ LOGGER.debug(
+ "Enable file station api updates for '%s'", self._entry.unique_id
+ )
+ self.file_station = self.dsm.file
+
if self._with_security:
LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id)
self.security = self.dsm.security
diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py
index 918a24035f8..b4453366718 100644
--- a/homeassistant/components/synology_dsm/config_flow.py
+++ b/homeassistant/components/synology_dsm/config_flow.py
@@ -3,12 +3,14 @@
from __future__ import annotations
from collections.abc import Mapping
+from contextlib import suppress
from ipaddress import ip_address as ip
import logging
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
from synology_dsm import SynologyDSM
+from synology_dsm.api.file_station.models import SynoFileSharedFolder
from synology_dsm.exceptions import (
SynologyDSMException,
SynologyDSMLogin2SAFailedException,
@@ -18,7 +20,6 @@ from synology_dsm.exceptions import (
)
import voluptuous as vol
-from homeassistant.components import ssdp, zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -39,15 +40,31 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType
+from homeassistant.util import slugify
from homeassistant.util.network import is_ip_address as is_ip
from .const import (
+ CONF_BACKUP_PATH,
+ CONF_BACKUP_SHARE,
CONF_DEVICE_TOKEN,
CONF_SNAPSHOT_QUALITY,
CONF_VOLUMES,
+ DEFAULT_BACKUP_PATH,
DEFAULT_PORT,
DEFAULT_PORT_SSL,
DEFAULT_SCAN_INTERVAL,
@@ -56,7 +73,9 @@ from .const import (
DEFAULT_USE_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
+ SYNOLOGY_CONNECTION_EXCEPTIONS,
)
+from .models import SynologyDSMData
_LOGGER = logging.getLogger(__name__)
@@ -126,6 +145,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
self.discovered_conf: dict[str, Any] = {}
self.reauth_conf: Mapping[str, Any] = {}
self.reauth_reason: str | None = None
+ self.shares: list[SynoFileSharedFolder] | None = None
def _show_form(
self,
@@ -168,6 +188,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
otp_code = user_input.get(CONF_OTP_CODE)
friendly_name = user_input.get(CONF_NAME)
+ backup_path = user_input.get(CONF_BACKUP_PATH)
+ backup_share = user_input.get(CONF_BACKUP_SHARE)
if not port:
if use_ssl is True:
@@ -204,6 +226,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
if errors:
return self._show_form(step_id, user_input, errors)
+ with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
+ self.shares = await api.file.get_shared_folders(only_writable=True)
+
+ if self.shares and not backup_path:
+ return await self.async_step_backup_share(user_input)
+
# unique_id should be serial for services purpose
existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False)
@@ -216,6 +244,10 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PASSWORD: password,
CONF_MAC: api.network.macs,
}
+ config_options = {
+ CONF_BACKUP_PATH: backup_path,
+ CONF_BACKUP_SHARE: backup_share,
+ }
if otp_code:
config_data[CONF_DEVICE_TOKEN] = api.device_token
if user_input.get(CONF_DISKS):
@@ -228,10 +260,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
"reauth_successful" if self.reauth_conf else "reconfigure_successful"
)
return self.async_update_reload_and_abort(
- existing_entry, data=config_data, reason=reason
+ existing_entry, data=config_data, options=config_options, reason=reason
)
- return self.async_create_entry(title=friendly_name or host, data=config_data)
+ return self.async_create_entry(
+ title=friendly_name or host, data=config_data, options=config_options
+ )
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -243,7 +277,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_validate_input_create_entry(user_input, step_id=step)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered synology_dsm via zeroconf."""
discovered_macs = [
@@ -258,13 +292,13 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._async_from_discovery(host, friendly_name, discovered_macs)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered synology_dsm via ssdp."""
parsed_url = urlparse(discovery_info.ssdp_location)
- upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
+ upnp_friendly_name: str = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]
friendly_name = upnp_friendly_name.split("(", 1)[0].strip()
- mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
+ mac_address = discovery_info.upnp[ATTR_UPNP_SERIAL]
discovered_macs = [format_synology_mac(mac_address)]
# Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets.
# The serial of the NAS is actually its MAC address.
@@ -363,6 +397,43 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_user(user_input)
+ async def async_step_backup_share(
+ self, user_input: dict[str, Any], errors: dict[str, str] | None = None
+ ) -> ConfigFlowResult:
+ """Select backup location."""
+ if TYPE_CHECKING:
+ assert self.shares is not None
+
+ if not self.saved_user_input:
+ self.saved_user_input = user_input
+
+ if CONF_BACKUP_PATH not in user_input and CONF_BACKUP_SHARE not in user_input:
+ return self.async_show_form(
+ step_id="backup_share",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_BACKUP_SHARE): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(value=s.path, label=s.name)
+ for s in self.shares
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ ),
+ ),
+ vol.Required(
+ CONF_BACKUP_PATH,
+ default=f"{DEFAULT_BACKUP_PATH}_{slugify(self.hass.config.location_name)}",
+ ): str,
+ }
+ ),
+ )
+
+ user_input = {**self.saved_user_input, **user_input}
+ self.saved_user_input = {}
+
+ return await self.async_step_user(user_input)
+
def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None:
"""See if we already have a configured NAS with this MAC address."""
for entry in self._async_current_entries():
@@ -383,6 +454,8 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
+ syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id]
+
data_schema = vol.Schema(
{
vol.Required(
@@ -399,6 +472,36 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow):
): vol.All(vol.Coerce(int), vol.Range(min=0, max=2)),
}
)
+
+ shares: list[SynoFileSharedFolder] | None = None
+ if syno_data.api.file_station:
+ with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
+ shares = await syno_data.api.file_station.get_shared_folders(
+ only_writable=True
+ )
+
+ if shares:
+ data_schema = data_schema.extend(
+ {
+ vol.Required(
+ CONF_BACKUP_SHARE,
+ default=self.config_entry.options[CONF_BACKUP_SHARE],
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(value=s.path, label=s.name)
+ for s in shares
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ ),
+ ),
+ vol.Required(
+ CONF_BACKUP_PATH,
+ default=self.config_entry.options[CONF_BACKUP_PATH],
+ ): str,
+ }
+ )
+
return self.async_show_form(step_id="init", data_schema=data_schema)
diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py
index e6367458578..dbee85b99d6 100644
--- a/homeassistant/components/synology_dsm/const.py
+++ b/homeassistant/components/synology_dsm/const.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from collections.abc import Callable
+
from aiohttp import ClientTimeout
from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED
from synology_dsm.exceptions import (
@@ -15,8 +17,12 @@ from synology_dsm.exceptions import (
)
from homeassistant.const import Platform
+from homeassistant.util.hass_dict import HassKey
DOMAIN = "synology_dsm"
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}_backup_agent_listeners"
+)
ATTRIBUTION = "Data provided by Synology"
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -34,6 +40,8 @@ CONF_SERIAL = "serial"
CONF_VOLUMES = "volumes"
CONF_DEVICE_TOKEN = "device_token"
CONF_SNAPSHOT_QUALITY = "snap_profile_type"
+CONF_BACKUP_SHARE = "backup_share"
+CONF_BACKUP_PATH = "backup_path"
DEFAULT_USE_SSL = True
DEFAULT_VERIFY_SSL = False
@@ -43,6 +51,7 @@ DEFAULT_PORT_SSL = 5001
DEFAULT_SCAN_INTERVAL = 15 # min
DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15)
DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED
+DEFAULT_BACKUP_PATH = "ha_backup"
ENTITY_UNIT_LOAD = "load"
diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py
index 357de10b5b8..30d1260ef32 100644
--- a/homeassistant/components/synology_dsm/coordinator.py
+++ b/homeassistant/components/synology_dsm/coordinator.py
@@ -59,6 +59,8 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R](
class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""DataUpdateCoordinator base class for synology_dsm."""
+ config_entry: ConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
@@ -68,10 +70,10 @@ class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
) -> None:
"""Initialize synology_dsm DataUpdateCoordinator."""
self.api = api
- self.entry = entry
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=f"{entry.title} {self.__class__.__name__}",
update_interval=update_interval,
)
@@ -174,7 +176,7 @@ class SynologyDSMCameraUpdateCoordinator(
):
async_dispatcher_send(
self.hass,
- f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}",
+ f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.config_entry.entry_id}_{cam_id}",
cam_data_new.live_view.rtsp,
)
diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json
index b85189715ef..ab6fc20b5cb 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.5.3"],
+ "requirements": ["py-synologydsm-api==2.6.0"],
"ssdp": [
{
"manufacturer": "Synology",
diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json
index 0f8ea594732..d6d40be3fea 100644
--- a/homeassistant/components/synology_dsm/strings.json
+++ b/homeassistant/components/synology_dsm/strings.json
@@ -21,6 +21,17 @@
"otp_code": "Code"
}
},
+ "backup_share": {
+ "title": "Synology DSM: Backup location",
+ "data": {
+ "backup_share": "Shared folder",
+ "backup_path": "Path"
+ },
+ "data_description": {
+ "backup_share": "Select the shared folder, where the automatic Home-Assistant backup should be stored.",
+ "backup_path": "Define the path on the selected shared folder (will automatically be created, if not exist)."
+ }
+ },
"link": {
"description": "Do you want to set up {name} ({host})?",
"data": {
@@ -59,7 +70,13 @@
"data": {
"scan_interval": "Minutes between scans",
"timeout": "Timeout (seconds)",
- "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)"
+ "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)",
+ "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
+ "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
+ },
+ "data_description": {
+ "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]",
+ "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]"
}
}
}
diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py
index 3e0e7add185..b916be84acf 100644
--- a/homeassistant/components/synology_srm/device_tracker.py
+++ b/homeassistant/components/synology_srm/device_tracker.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py
index 98396e52545..60b57b1e87f 100644
--- a/homeassistant/components/system_bridge/config_flow.py
+++ b/homeassistant/components/system_bridge/config_flow.py
@@ -16,13 +16,13 @@ from systembridgeconnector.websocket_client import WebSocketClient
from systembridgemodels.modules import GetData, Module
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DATA_WAIT_TIMEOUT, DOMAIN
@@ -179,7 +179,7 @@ class SystemBridgeConfigFlow(
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
properties = discovery_info.properties
diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py
index ce80f6303d9..7d2224fc6fc 100644
--- a/homeassistant/components/system_health/__init__.py
+++ b/homeassistant/components/system_health/__init__.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from collections.abc import Awaitable, Callable
+from collections.abc import AsyncGenerator, Awaitable, Callable
import dataclasses
from datetime import datetime
import logging
@@ -101,6 +101,57 @@ async def get_integration_info(
return result
+async def _registered_domain_data(
+ hass: HomeAssistant,
+) -> AsyncGenerator[tuple[str, dict[str, Any]]]:
+ registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN]
+ for domain, domain_data in zip(
+ registrations,
+ await asyncio.gather(
+ *(
+ get_integration_info(hass, registration)
+ for registration in registrations.values()
+ )
+ ),
+ strict=False,
+ ):
+ yield domain, domain_data
+
+
+async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]:
+ """Get the full set of system health information."""
+ domains: dict[str, dict[str, Any]] = {}
+
+ async def _get_info_value(value: Any) -> Any:
+ if not asyncio.iscoroutine(value):
+ return value
+ try:
+ return await value
+ except Exception as exception:
+ _LOGGER.exception("Error fetching system info for %s - %s", domain, key)
+ return f"Exception: {exception}"
+
+ async for domain, domain_data in _registered_domain_data(hass):
+ domain_info: dict[str, Any] = {}
+ for key, value in domain_data["info"].items():
+ info_value = await _get_info_value(value)
+
+ if isinstance(info_value, datetime):
+ domain_info[key] = info_value.isoformat()
+ elif (
+ isinstance(info_value, dict)
+ and "type" in info_value
+ and info_value["type"] == "failed"
+ ):
+ domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}"
+ else:
+ domain_info[key] = info_value
+
+ domains[domain] = domain_info
+
+ return domains
+
+
@callback
def _format_value(val: Any) -> Any:
"""Format a system health value."""
@@ -115,20 +166,10 @@ async def handle_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle an info request via a subscription."""
- registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN]
data = {}
pending_info: dict[tuple[str, str], asyncio.Task] = {}
- for domain, domain_data in zip(
- registrations,
- await asyncio.gather(
- *(
- get_integration_info(hass, registration)
- for registration in registrations.values()
- )
- ),
- strict=False,
- ):
+ async for domain, domain_data in _registered_domain_data(hass):
for key, value in domain_data["info"].items():
if asyncio.iscoroutine(value):
value = asyncio.create_task(value)
diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py
index 22950aa9f1e..facfb270627 100644
--- a/homeassistant/components/system_log/__init__.py
+++ b/homeassistant/components/system_log/__init__.py
@@ -16,7 +16,7 @@ from homeassistant import __path__ as HOMEASSISTANT_PATH
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
type KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None]
@@ -163,16 +163,16 @@ class LogEntry:
"""Store HA log entries."""
__slots__ = (
+ "count",
+ "exception",
"first_occurred",
- "timestamp",
- "name",
+ "key",
"level",
"message",
- "exception",
+ "name",
"root_cause",
"source",
- "count",
- "key",
+ "timestamp",
)
def __init__(
diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py
index 4a794a00432..2776feba272 100644
--- a/homeassistant/components/systemmonitor/__init__.py
+++ b/homeassistant/components/systemmonitor/__init__.py
@@ -50,7 +50,7 @@ async def async_setup_entry(
_LOGGER.debug("disk arguments to be added: %s", disk_arguments)
coordinator: SystemMonitorCoordinator = SystemMonitorCoordinator(
- hass, psutil_wrapper, disk_arguments
+ hass, entry, psutil_wrapper, disk_arguments
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = SystemMonitorData(coordinator, psutil_wrapper)
@@ -60,17 +60,21 @@ async def async_setup_entry(
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: SystemMonitorConfigEntry
+) -> bool:
"""Unload System Monitor config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: SystemMonitorConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, entry: SystemMonitorConfigEntry
+) -> bool:
"""Migrate old entry."""
if entry.version > 1:
diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py
index 34b28a1d47a..4be31f6944c 100644
--- a/homeassistant/components/systemmonitor/config_flow.py
+++ b/homeassistant/components/systemmonitor/config_flow.py
@@ -8,8 +8,6 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
-from homeassistant.config_entries import ConfigFlowResult
-from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
@@ -100,12 +98,3 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return "System Monitor"
-
- @callback
- def async_create_entry(
- self, data: Mapping[str, Any], **kwargs: Any
- ) -> ConfigFlowResult:
- """Finish config flow and create a config entry."""
- if self._async_current_entries():
- return self.async_abort(reason="already_configured")
- return super().async_create_entry(data, **kwargs)
diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py
index 32a171a11ca..03b769ee2e2 100644
--- a/homeassistant/components/systemmonitor/coordinator.py
+++ b/homeassistant/components/systemmonitor/coordinator.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime
import logging
import os
-from typing import Any, NamedTuple
+from typing import TYPE_CHECKING, Any, NamedTuple
from psutil import Process
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
@@ -17,6 +17,9 @@ from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from homeassistant.util import dt as dt_util
+if TYPE_CHECKING:
+ from . import SystemMonitorConfigEntry
+
_LOGGER = logging.getLogger(__name__)
@@ -83,6 +86,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
def __init__(
self,
hass: HomeAssistant,
+ config_entry: SystemMonitorConfigEntry,
psutil_wrapper: ha_psutil.PsutilWrapper,
arguments: list[str],
) -> None:
@@ -90,6 +94,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name="System Monitor update coordinator",
update_interval=DEFAULT_SCAN_INTERVAL,
always_update=False,
diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json
index 4c6ae0653d3..bd16464b290 100644
--- a/homeassistant/components/systemmonitor/manifest.json
+++ b/homeassistant/components/systemmonitor/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/systemmonitor",
"iot_class": "local_push",
"loggers": ["psutil"],
- "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.0"]
+ "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json
index e595e628853..fb8a318ff45 100644
--- a/homeassistant/components/systemmonitor/strings.json
+++ b/homeassistant/components/systemmonitor/strings.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ "single_instance_allowed": "[%key:common::config_flow::abort::already_configured_service%]"
},
"step": {
"user": {
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
index cc5dee77617..3e42e33489f 100644
--- a/homeassistant/components/tado/__init__.py
+++ b/homeassistant/components/tado/__init__.py
@@ -3,14 +3,15 @@
from datetime import timedelta
import logging
-import requests.exceptions
+import PyTado
+import PyTado.exceptions
+from PyTado.interface import Tado
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -21,11 +22,9 @@ from .const import (
CONST_OVERLAY_TADO_OPTIONS,
DOMAIN,
)
+from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator
+from .models import TadoData
from .services import setup_services
-from .tado_connector import TadoConnector
-
-_LOGGER = logging.getLogger(__name__)
-
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -41,16 +40,17 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Tado."""
setup_services(hass)
-
return True
-type TadoConfigEntry = ConfigEntry[TadoConnector]
+type TadoConfigEntry = ConfigEntry[TadoData]
async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
@@ -58,53 +58,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool
_async_import_options_from_data_if_missing(hass, entry)
- username = entry.data[CONF_USERNAME]
- password = entry.data[CONF_PASSWORD]
- fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT)
-
- tadoconnector = TadoConnector(hass, username, password, fallback)
-
+ _LOGGER.debug("Setting up Tado connection")
try:
- await hass.async_add_executor_job(tadoconnector.setup)
- except KeyError:
- _LOGGER.error("Failed to login to tado")
- return False
- except RuntimeError as exc:
- _LOGGER.error("Failed to setup tado: %s", exc)
- return False
- except requests.exceptions.Timeout as ex:
- raise ConfigEntryNotReady from ex
- except requests.exceptions.HTTPError as ex:
- if ex.response.status_code > 400 and ex.response.status_code < 500:
- _LOGGER.error("Failed to login to tado: %s", ex)
- return False
- raise ConfigEntryNotReady from ex
-
- # Do first update
- await hass.async_add_executor_job(tadoconnector.update)
-
- # Poll for updates in the background
- entry.async_on_unload(
- async_track_time_interval(
- hass,
- lambda now: tadoconnector.update(),
- SCAN_INTERVAL,
+ tado = await hass.async_add_executor_job(
+ Tado,
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_PASSWORD],
)
+ except PyTado.exceptions.TadoWrongCredentialsException as err:
+ raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err
+ except PyTado.exceptions.TadoException as err:
+ raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err
+ _LOGGER.debug(
+ "Tado connection established for username: %s", entry.data[CONF_USERNAME]
)
- entry.async_on_unload(
- async_track_time_interval(
- hass,
- lambda now: tadoconnector.update_mobile_devices(),
- SCAN_MOBILE_DEVICE_INTERVAL,
- )
- )
+ coordinator = TadoDataUpdateCoordinator(hass, entry, tado)
+ await coordinator.async_config_entry_first_refresh()
- entry.async_on_unload(entry.add_update_listener(_async_update_listener))
-
- entry.runtime_data = tadoconnector
+ mobile_coordinator = TadoMobileDeviceUpdateCoordinator(hass, entry, tado)
+ await mobile_coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = TadoData(coordinator, mobile_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -126,7 +103,7 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi
hass.config_entries.async_update_entry(entry, options=options)
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py
index 25c1c801155..c969ea34f42 100644
--- a/homeassistant/components/tado/binary_sensor.py
+++ b/homeassistant/components/tado/binary_sensor.py
@@ -13,21 +13,19 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TadoConfigEntry
from .const import (
- SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING,
TYPE_BATTERY,
TYPE_HEATING,
TYPE_HOT_WATER,
TYPE_POWER,
)
+from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoDeviceEntity, TadoZoneEntity
-from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
@@ -121,7 +119,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado sensor platform."""
- tado = entry.runtime_data
+ tado = entry.runtime_data.coordinator
devices = tado.devices
zones = tado.zones
entities: list[BinarySensorEntity] = []
@@ -164,43 +162,23 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity):
def __init__(
self,
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
device_info: dict[str, Any],
entity_description: TadoBinarySensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
- self._tado = tado
- super().__init__(device_info)
+ super().__init__(device_info, coordinator)
self._attr_unique_id = (
- f"{entity_description.key} {self.device_id} {tado.home_id}"
+ f"{entity_description.key} {self.device_id} {coordinator.home_id}"
)
- async def async_added_to_hass(self) -> None:
- """Register for sensor updates."""
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self._tado.home_id, "device", self.device_id
- ),
- self._async_update_callback,
- )
- )
- self._async_update_device_data()
-
@callback
- def _async_update_callback(self) -> None:
- """Update and write state."""
- self._async_update_device_data()
- self.async_write_ha_state()
-
- @callback
- def _async_update_device_data(self) -> None:
- """Handle update callbacks."""
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
try:
- self._device_info = self._tado.data["device"][self.device_id]
+ self._device_info = self.coordinator.data["device"][self.device_id]
except KeyError:
return
@@ -209,6 +187,7 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
self._device_info
)
+ super()._handle_coordinator_update()
class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
@@ -218,42 +197,24 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
def __init__(
self,
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
entity_description: TadoBinarySensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
- self._tado = tado
- super().__init__(zone_name, tado.home_id, zone_id)
+ super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
- self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}"
-
- async def async_added_to_hass(self) -> None:
- """Register for sensor updates."""
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self._tado.home_id, "zone", self.zone_id
- ),
- self._async_update_callback,
- )
+ self._attr_unique_id = (
+ f"{entity_description.key} {zone_id} {coordinator.home_id}"
)
- self._async_update_zone_data()
@callback
- def _async_update_callback(self) -> None:
- """Update and write state."""
- self._async_update_zone_data()
- self.async_write_ha_state()
-
- @callback
- def _async_update_zone_data(self) -> None:
- """Handle update callbacks."""
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
try:
- tado_zone_data = self._tado.data["zone"][self.zone_id]
+ tado_zone_data = self.coordinator.data["zone"][self.zone_id]
except KeyError:
return
@@ -262,3 +223,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_zone_data
)
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py
index 5a81e951293..db7b1823bd9 100644
--- a/homeassistant/components/tado/climate.py
+++ b/homeassistant/components/tado/climate.py
@@ -26,11 +26,10 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
-from . import TadoConfigEntry, TadoConnector
+from . import TadoConfigEntry
from .const import (
CONST_EXCLUSIVE_OVERLAY_GROUP,
CONST_FAN_AUTO,
@@ -50,7 +49,6 @@ from .const import (
HA_TO_TADO_HVAC_MODE_MAP,
ORDERED_KNOWN_TADO_MODES,
PRESET_AUTO,
- SIGNAL_TADO_UPDATE_RECEIVED,
SUPPORT_PRESET_AUTO,
SUPPORT_PRESET_MANUAL,
TADO_DEFAULT_MAX_TEMP,
@@ -73,6 +71,7 @@ from .const import (
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
)
+from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoZoneEntity
from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes
@@ -105,8 +104,8 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado climate platform."""
- tado = entry.runtime_data
- entities = await hass.async_add_executor_job(_generate_entities, tado)
+ tado = entry.runtime_data.coordinator
+ entities = await _generate_entities(tado)
platform = entity_platform.async_get_current_platform()
@@ -125,12 +124,12 @@ async def async_setup_entry(
async_add_entities(entities, True)
-def _generate_entities(tado: TadoConnector) -> list[TadoClimate]:
+async def _generate_entities(tado: TadoDataUpdateCoordinator) -> list[TadoClimate]:
"""Create all climate entities."""
entities = []
for zone in tado.zones:
if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]:
- entity = create_climate_entity(
+ entity = await create_climate_entity(
tado, zone["name"], zone["id"], zone["devices"][0]
)
if entity:
@@ -138,11 +137,11 @@ def _generate_entities(tado: TadoConnector) -> list[TadoClimate]:
return entities
-def create_climate_entity(
- tado: TadoConnector, name: str, zone_id: int, device_info: dict
+async def create_climate_entity(
+ tado: TadoDataUpdateCoordinator, name: str, zone_id: int, device_info: dict
) -> TadoClimate | None:
"""Create a Tado climate entity."""
- capabilities = tado.get_capabilities(zone_id)
+ capabilities = await tado.get_capabilities(zone_id)
_LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities)
zone_type = capabilities["type"]
@@ -243,6 +242,8 @@ def create_climate_entity(
cool_max_temp = float(cool_temperatures["celsius"]["max"])
cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS)
+ auto_geofencing_supported = await tado.get_auto_geofencing_supported()
+
return TadoClimate(
tado,
name,
@@ -251,6 +252,8 @@ def create_climate_entity(
supported_hvac_modes,
support_flags,
device_info,
+ capabilities,
+ auto_geofencing_supported,
heat_min_temp,
heat_max_temp,
heat_step,
@@ -272,13 +275,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
def __init__(
self,
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
zone_type: str,
supported_hvac_modes: list[HVACMode],
support_flags: ClimateEntityFeature,
device_info: dict[str, str],
+ capabilities: dict[str, str],
+ auto_geofencing_supported: bool,
heat_min_temp: float | None = None,
heat_max_temp: float | None = None,
heat_step: float | None = None,
@@ -289,13 +294,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
supported_swing_modes: list[str] | None = None,
) -> None:
"""Initialize of Tado climate entity."""
- self._tado = tado
- super().__init__(zone_name, tado.home_id, zone_id)
+ self._tado = coordinator
+ super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self.zone_id = zone_id
self.zone_type = zone_type
- self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}"
+ self._attr_unique_id = f"{zone_type} {zone_id} {coordinator.home_id}"
self._device_info = device_info
self._device_id = self._device_info["shortSerialNo"]
@@ -327,36 +332,61 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self._current_tado_vertical_swing = TADO_SWING_OFF
self._current_tado_horizontal_swing = TADO_SWING_OFF
- capabilities = tado.get_capabilities(zone_id)
self._current_tado_capabilities = capabilities
+ self._auto_geofencing_supported = auto_geofencing_supported
self._tado_zone_data: PyTado.TadoZone = {}
self._tado_geofence_data: dict[str, str] | None = None
self._tado_zone_temp_offset: dict[str, Any] = {}
- self._async_update_home_data()
self._async_update_zone_data()
- async def async_added_to_hass(self) -> None:
- """Register for sensor updates."""
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"),
- self._async_update_home_callback,
- )
- )
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._async_update_zone_data()
+ super()._handle_coordinator_update()
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self._tado.home_id, "zone", self.zone_id
- ),
- self._async_update_zone_callback,
+ @callback
+ def _async_update_zone_data(self) -> None:
+ """Load tado data into zone."""
+ self._tado_geofence_data = self._tado.data["geofence"]
+ self._tado_zone_data = self._tado.data["zone"][self.zone_id]
+
+ # Assign offset values to mapped attributes
+ for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items():
+ if (
+ self._device_id in self._tado.data["device"]
+ and offset_key
+ in self._tado.data["device"][self._device_id][TEMP_OFFSET]
+ ):
+ self._tado_zone_temp_offset[attr] = self._tado.data["device"][
+ self._device_id
+ ][TEMP_OFFSET][offset_key]
+
+ self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
+ self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
+
+ if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING):
+ self._current_tado_fan_level = self._tado_zone_data.current_fan_level
+ if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING):
+ self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
+ if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING):
+ self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode
+ if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING):
+ self._current_tado_vertical_swing = (
+ self._tado_zone_data.current_vertical_swing_mode
)
- )
+ if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
+ self._current_tado_horizontal_swing = (
+ self._tado_zone_data.current_horizontal_swing_mode
+ )
+
+ @callback
+ def _async_update_zone_callback(self) -> None:
+ """Load tado data and update state."""
+ self._async_update_zone_data()
@property
def current_humidity(self) -> int | None:
@@ -401,12 +431,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
return FAN_AUTO
return None
- def set_fan_mode(self, fan_mode: str) -> None:
+ async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Turn fan on/off."""
if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING):
- self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode])
+ await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode])
elif self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING):
- self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
+ await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
+ await self.coordinator.async_request_refresh()
@property
def preset_mode(self) -> str:
@@ -425,13 +456,14 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
@property
def preset_modes(self) -> list[str]:
"""Return a list of available preset modes."""
- if self._tado.get_auto_geofencing_supported():
+ if self._auto_geofencing_supported:
return SUPPORT_PRESET_AUTO
return SUPPORT_PRESET_MANUAL
- def set_preset_mode(self, preset_mode: str) -> None:
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
- self._tado.set_presence(preset_mode)
+ await self._tado.set_presence(preset_mode)
+ await self.coordinator.async_request_refresh()
@property
def target_temperature_step(self) -> float | None:
@@ -449,7 +481,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
# the device is switching states
return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
- def set_timer(
+ async def set_timer(
self,
temperature: float,
time_period: int | None = None,
@@ -457,14 +489,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
):
"""Set the timer on the entity, and temperature if supported."""
- self._control_hvac(
+ await self._control_hvac(
hvac_mode=CONST_MODE_HEAT,
target_temp=temperature,
duration=time_period,
overlay_mode=requested_overlay,
)
+ await self.coordinator.async_request_refresh()
- def set_temp_offset(self, offset: float) -> None:
+ async def set_temp_offset(self, offset: float) -> None:
"""Set offset on the entity."""
_LOGGER.debug(
@@ -473,9 +506,10 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
offset,
)
- self._tado.set_temperature_offset(self._device_id, offset)
+ await self._tado.set_temperature_offset(self._device_id, offset)
+ await self.coordinator.async_request_refresh()
- def set_temperature(self, **kwargs: Any) -> None:
+ async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
@@ -485,15 +519,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
- self._control_hvac(target_temp=temperature)
+ await self._control_hvac(target_temp=temperature)
+ await self.coordinator.async_request_refresh()
return
new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT
- self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode)
+ await self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode)
+ await self.coordinator.async_request_refresh()
- def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
- self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode])
+ _LOGGER.debug(
+ "Setting new hvac mode for device %s to %s", self._device_id, hvac_mode
+ )
+ await self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode])
+ await self.coordinator.async_request_refresh()
@property
def available(self) -> bool:
@@ -559,7 +599,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
)
return state_attr
- def set_swing_mode(self, swing_mode: str) -> None:
+ async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set swing modes for the device."""
vertical_swing = None
horizontal_swing = None
@@ -591,62 +631,12 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
horizontal_swing = TADO_SWING_ON
- self._control_hvac(
+ await self._control_hvac(
swing_mode=swing,
vertical_swing=vertical_swing,
horizontal_swing=horizontal_swing,
)
-
- @callback
- def _async_update_zone_data(self) -> None:
- """Load tado data into zone."""
- self._tado_zone_data = self._tado.data["zone"][self.zone_id]
-
- # Assign offset values to mapped attributes
- for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items():
- if (
- self._device_id in self._tado.data["device"]
- and offset_key
- in self._tado.data["device"][self._device_id][TEMP_OFFSET]
- ):
- self._tado_zone_temp_offset[attr] = self._tado.data["device"][
- self._device_id
- ][TEMP_OFFSET][offset_key]
-
- self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
- self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
-
- if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING):
- self._current_tado_fan_level = self._tado_zone_data.current_fan_level
- if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING):
- self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
- if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING):
- self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode
- if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING):
- self._current_tado_vertical_swing = (
- self._tado_zone_data.current_vertical_swing_mode
- )
- if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
- self._current_tado_horizontal_swing = (
- self._tado_zone_data.current_horizontal_swing_mode
- )
-
- @callback
- def _async_update_zone_callback(self) -> None:
- """Load tado data and update state."""
- self._async_update_zone_data()
- self.async_write_ha_state()
-
- @callback
- def _async_update_home_data(self) -> None:
- """Load tado geofencing data into zone."""
- self._tado_geofence_data = self._tado.data["geofence"]
-
- @callback
- def _async_update_home_callback(self) -> None:
- """Load tado data and update state."""
- self._async_update_home_data()
- self.async_write_ha_state()
+ await self.coordinator.async_request_refresh()
def _normalize_target_temp_for_hvac_mode(self) -> None:
def adjust_temp(min_temp, max_temp) -> float | None:
@@ -665,7 +655,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
elif self._current_tado_hvac_mode == CONST_MODE_HEAT:
self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp)
- def _control_hvac(
+ async def _control_hvac(
self,
hvac_mode: str | None = None,
target_temp: float | None = None,
@@ -712,7 +702,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
_LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id
)
- self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type)
+ await self._tado.set_zone_off(
+ self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type
+ )
return
if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE:
@@ -721,17 +713,17 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self.zone_name,
self.zone_id,
)
- self._tado.reset_zone_overlay(self.zone_id)
+ await self._tado.reset_zone_overlay(self.zone_id)
return
overlay_mode = decide_overlay_mode(
- tado=self._tado,
+ coordinator=self._tado,
duration=duration,
overlay_mode=overlay_mode,
zone_id=self.zone_id,
)
duration = decide_duration(
- tado=self._tado,
+ coordinator=self._tado,
duration=duration,
zone_id=self.zone_id,
overlay_mode=overlay_mode,
@@ -785,7 +777,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
):
swing = self._current_tado_swing_mode
- self._tado.set_zone_overlay(
+ await self._tado.set_zone_overlay(
zone_id=self.zone_id,
overlay_mode=overlay_mode, # What to do when the period ends
temperature=temperature_to_send,
@@ -800,18 +792,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
)
def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool:
- return (
- self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get(
- setting
- )
- is not None
+ """Determine if a setting is valid for the current HVAC mode."""
+ capabilities: str | dict[str, str] = self._current_tado_capabilities.get(
+ self._current_tado_hvac_mode, {}
)
+ if isinstance(capabilities, dict):
+ return capabilities.get(setting) is not None
+ return False
def _is_current_setting_supported_by_current_hvac_mode(
self, setting: str, current_state: str | None
) -> bool:
- if self._is_valid_setting_for_hvac_mode(setting):
- return current_state in self._current_tado_capabilities[
- self._current_tado_hvac_mode
- ].get(setting, [])
+ """Determine if the current setting is supported by the current HVAC mode."""
+ capabilities: str | dict[str, str] = self._current_tado_capabilities.get(
+ self._current_tado_hvac_mode, {}
+ )
+ if isinstance(capabilities, dict) and self._is_valid_setting_for_hvac_mode(
+ setting
+ ):
+ return current_state in capabilities.get(setting, [])
return False
diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py
index c7bb7684901..f251a292800 100644
--- a/homeassistant/components/tado/config_flow.py
+++ b/homeassistant/components/tado/config_flow.py
@@ -10,7 +10,6 @@ from PyTado.interface import Tado
import requests.exceptions
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -20,6 +19,10 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo,
+)
from .const import (
CONF_FALLBACK,
@@ -49,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
tado = await hass.async_add_executor_job(
Tado, data[CONF_USERNAME], data[CONF_PASSWORD]
)
- tado_me = await hass.async_add_executor_job(tado.getMe)
+ tado_me = await hass.async_add_executor_job(tado.get_me)
except KeyError as ex:
raise InvalidAuth from ex
except RuntimeError as ex:
@@ -104,14 +107,14 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle HomeKit discovery."""
self._async_abort_entries_match()
properties = {
key.lower(): value for (key, value) in discovery_info.properties.items()
}
- await self.async_set_unique_id(properties[zeroconf.ATTR_PROPERTIES_ID])
+ await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID])
self._abort_if_unique_id_configured()
return await self.async_step_user()
diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py
new file mode 100644
index 00000000000..ddec9e7f292
--- /dev/null
+++ b/homeassistant/components/tado/coordinator.py
@@ -0,0 +1,391 @@
+"""Coordinator for the Tado integration."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+import logging
+from typing import Any
+
+from PyTado.interface import Tado
+from requests import RequestException
+
+from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import (
+ CONF_FALLBACK,
+ CONST_OVERLAY_TADO_DEFAULT,
+ DOMAIN,
+ INSIDE_TEMPERATURE_MEASUREMENT,
+ PRESET_AUTO,
+ TEMP_OFFSET,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4)
+SCAN_INTERVAL = timedelta(minutes=5)
+SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
+
+type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator]
+
+
+class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
+ """Class to manage API calls from and to Tado via PyTado."""
+
+ tado: Tado
+ home_id: int
+ home_name: str
+ config_entry: TadoConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ tado: Tado,
+ debug: bool = False,
+ ) -> None:
+ """Initialize the Tado data update coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self._tado = tado
+ self._username = entry.data[CONF_USERNAME]
+ self._password = entry.data[CONF_PASSWORD]
+ self._fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT)
+ self._debug = debug
+
+ self.home_id: int
+ self.home_name: str
+ self.zones: list[dict[Any, Any]] = []
+ self.devices: list[dict[Any, Any]] = []
+ self.data: dict[str, dict] = {
+ "device": {},
+ "weather": {},
+ "geofence": {},
+ "zone": {},
+ }
+
+ @property
+ def fallback(self) -> str:
+ """Return fallback flag to Smart Schedule."""
+ return self._fallback
+
+ async def _async_update_data(self) -> dict[str, dict]:
+ """Fetch the (initial) latest data from Tado."""
+
+ try:
+ _LOGGER.debug("Preloading home data")
+ tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me)
+ _LOGGER.debug("Preloading zones and devices")
+ self.zones = await self.hass.async_add_executor_job(self._tado.get_zones)
+ self.devices = await self.hass.async_add_executor_job(
+ self._tado.get_devices
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error during Tado setup: {err}") from err
+
+ tado_home = tado_home_call["homes"][0]
+ self.home_id = tado_home["id"]
+ self.home_name = tado_home["name"]
+
+ devices = await self._async_update_devices()
+ zones = await self._async_update_zones()
+ home = await self._async_update_home()
+
+ self.data["device"] = devices
+ self.data["zone"] = zones
+ self.data["weather"] = home["weather"]
+ self.data["geofence"] = home["geofence"]
+
+ return self.data
+
+ async def _async_update_devices(self) -> dict[str, dict]:
+ """Update the device data from Tado."""
+
+ try:
+ devices = await self.hass.async_add_executor_job(self._tado.get_devices)
+ except RequestException as err:
+ _LOGGER.error("Error updating Tado devices: %s", err)
+ raise UpdateFailed(f"Error updating Tado devices: {err}") from err
+
+ if not devices:
+ _LOGGER.error("No linked devices found for home ID %s", self.home_id)
+ raise UpdateFailed(f"No linked devices found for home ID {self.home_id}")
+
+ return await self.hass.async_add_executor_job(self._update_device_info, devices)
+
+ def _update_device_info(self, devices: list[dict[str, Any]]) -> dict[str, dict]:
+ """Update the device data from Tado."""
+ mapped_devices: dict[str, dict] = {}
+ for device in devices:
+ device_short_serial_no = device["shortSerialNo"]
+ _LOGGER.debug("Updating device %s", device_short_serial_no)
+ try:
+ if (
+ INSIDE_TEMPERATURE_MEASUREMENT
+ in device["characteristics"]["capabilities"]
+ ):
+ _LOGGER.debug(
+ "Updating temperature offset for device %s",
+ device_short_serial_no,
+ )
+ device[TEMP_OFFSET] = self._tado.get_device_info(
+ device_short_serial_no, TEMP_OFFSET
+ )
+ except RequestException as err:
+ _LOGGER.error(
+ "Error updating device %s: %s", device_short_serial_no, err
+ )
+
+ _LOGGER.debug(
+ "Device %s updated, with data: %s", device_short_serial_no, device
+ )
+ mapped_devices[device_short_serial_no] = device
+
+ return mapped_devices
+
+ async def _async_update_zones(self) -> dict[int, dict]:
+ """Update the zone data from Tado."""
+
+ try:
+ zone_states_call = await self.hass.async_add_executor_job(
+ self._tado.get_zone_states
+ )
+ zone_states = zone_states_call["zoneStates"]
+ except RequestException as err:
+ _LOGGER.error("Error updating Tado zones: %s", err)
+ raise UpdateFailed(f"Error updating Tado zones: {err}") from err
+
+ mapped_zones: dict[int, dict] = {}
+ for zone in zone_states:
+ mapped_zones[int(zone)] = await self._update_zone(int(zone))
+
+ return mapped_zones
+
+ async def _update_zone(self, zone_id: int) -> dict[str, str]:
+ """Update the internal data of a zone."""
+
+ _LOGGER.debug("Updating zone %s", zone_id)
+ try:
+ data = await self.hass.async_add_executor_job(
+ self._tado.get_zone_state, zone_id
+ )
+ except RequestException as err:
+ _LOGGER.error("Error updating Tado zone %s: %s", zone_id, err)
+ raise UpdateFailed(f"Error updating Tado zone {zone_id}: {err}") from err
+
+ _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data)
+ return data
+
+ async def _async_update_home(self) -> dict[str, dict]:
+ """Update the home data from Tado."""
+
+ try:
+ weather = await self.hass.async_add_executor_job(self._tado.get_weather)
+ geofence = await self.hass.async_add_executor_job(self._tado.get_home_state)
+ except RequestException as err:
+ _LOGGER.error("Error updating Tado home: %s", err)
+ raise UpdateFailed(f"Error updating Tado home: {err}") from err
+
+ _LOGGER.debug(
+ "Home data updated, with weather and geofence data: %s, %s",
+ weather,
+ geofence,
+ )
+
+ return {"weather": weather, "geofence": geofence}
+
+ async def get_capabilities(self, zone_id: int | str) -> dict:
+ """Fetch the capabilities from Tado."""
+
+ try:
+ return await self.hass.async_add_executor_job(
+ self._tado.get_capabilities, zone_id
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error updating Tado data: {err}") from err
+
+ async def get_auto_geofencing_supported(self) -> bool:
+ """Fetch the auto geofencing supported from Tado."""
+
+ try:
+ return await self.hass.async_add_executor_job(
+ self._tado.get_auto_geofencing_supported
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error updating Tado data: {err}") from err
+
+ async def reset_zone_overlay(self, zone_id):
+ """Reset the zone back to the default operation."""
+
+ try:
+ await self.hass.async_add_executor_job(
+ self._tado.reset_zone_overlay, zone_id
+ )
+ await self._update_zone(zone_id)
+ except RequestException as err:
+ raise UpdateFailed(f"Error resetting Tado data: {err}") from err
+
+ async def set_presence(
+ self,
+ presence=PRESET_HOME,
+ ):
+ """Set the presence to home, away or auto."""
+
+ if presence == PRESET_AWAY:
+ await self.hass.async_add_executor_job(self._tado.set_away)
+ elif presence == PRESET_HOME:
+ await self.hass.async_add_executor_job(self._tado.set_home)
+ elif presence == PRESET_AUTO:
+ await self.hass.async_add_executor_job(self._tado.set_auto)
+
+ async def set_zone_overlay(
+ self,
+ zone_id=None,
+ overlay_mode=None,
+ temperature=None,
+ duration=None,
+ device_type="HEATING",
+ mode=None,
+ fan_speed=None,
+ swing=None,
+ fan_level=None,
+ vertical_swing=None,
+ horizontal_swing=None,
+ ) -> None:
+ """Set a zone overlay."""
+
+ _LOGGER.debug(
+ "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s, fan_speed=%s, swing=%s, fan_level=%s, vertical_swing=%s, horizontal_swing=%s",
+ zone_id,
+ overlay_mode,
+ temperature,
+ duration,
+ device_type,
+ mode,
+ fan_speed,
+ swing,
+ fan_level,
+ vertical_swing,
+ horizontal_swing,
+ )
+
+ try:
+ await self.hass.async_add_executor_job(
+ self._tado.set_zone_overlay,
+ zone_id,
+ overlay_mode,
+ temperature,
+ duration,
+ device_type,
+ "ON",
+ mode,
+ fan_speed,
+ swing,
+ fan_level,
+ vertical_swing,
+ horizontal_swing,
+ )
+
+ except RequestException as err:
+ raise UpdateFailed(f"Error setting Tado overlay: {err}") from err
+
+ await self._update_zone(zone_id)
+
+ async def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"):
+ """Set a zone to off."""
+ try:
+ await self.hass.async_add_executor_job(
+ self._tado.set_zone_overlay,
+ zone_id,
+ overlay_mode,
+ None,
+ None,
+ device_type,
+ "OFF",
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error setting Tado overlay: {err}") from err
+
+ await self._update_zone(zone_id)
+
+ async def set_temperature_offset(self, device_id, offset):
+ """Set temperature offset of device."""
+ try:
+ await self.hass.async_add_executor_job(
+ self._tado.set_temp_offset, device_id, offset
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error setting Tado temperature offset: {err}") from err
+
+ async def set_meter_reading(self, reading: int) -> dict[str, Any]:
+ """Send meter reading to Tado."""
+ dt: str = datetime.now().strftime("%Y-%m-%d")
+ if self._tado is None:
+ raise HomeAssistantError("Tado client is not initialized")
+
+ try:
+ return await self.hass.async_add_executor_job(
+ self._tado.set_eiq_meter_readings, dt, reading
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err
+
+
+class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
+ """Class to manage the mobile devices from Tado via PyTado."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ tado: Tado,
+ ) -> None:
+ """Initialize the Tado data update coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_MOBILE_DEVICE_INTERVAL,
+ )
+ self._tado = tado
+ self.data: dict[str, dict] = {}
+
+ async def _async_update_data(self) -> dict[str, dict]:
+ """Fetch the latest data from Tado."""
+
+ try:
+ mobile_devices = await self.hass.async_add_executor_job(
+ self._tado.get_mobile_devices
+ )
+ except RequestException as err:
+ _LOGGER.error("Error updating Tado mobile devices: %s", err)
+ raise UpdateFailed(f"Error updating Tado mobile devices: {err}") from err
+
+ mapped_mobile_devices: dict[str, dict] = {}
+ for mobile_device in mobile_devices:
+ mobile_device_id = mobile_device["id"]
+ _LOGGER.debug("Updating mobile device %s", mobile_device_id)
+ try:
+ mapped_mobile_devices[mobile_device_id] = mobile_device
+ _LOGGER.debug(
+ "Mobile device %s updated, with data: %s",
+ mobile_device_id,
+ mobile_device,
+ )
+ except RequestException:
+ _LOGGER.error(
+ "Unable to connect to Tado while updating mobile device %s",
+ mobile_device_id,
+ )
+
+ self.data["mobile_device"] = mapped_mobile_devices
+ return self.data
diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py
index 95e031329c3..a9be560f434 100644
--- a/homeassistant/components/tado/device_tracker.py
+++ b/homeassistant/components/tado/device_tracker.py
@@ -11,12 +11,15 @@ from homeassistant.components.device_tracker import (
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
from . import TadoConfigEntry
-from .const import DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED
-from .tado_connector import TadoConnector
+from .const import DOMAIN
+from .coordinator import TadoMobileDeviceUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +31,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado device scannery entity."""
_LOGGER.debug("Setting up Tado device scanner entity")
- tado = entry.runtime_data
+ tado = entry.runtime_data.mobile_coordinator
tracked: set = set()
# Fix non-string unique_id for device trackers
@@ -49,58 +52,56 @@ async def async_setup_entry(
update_devices()
- entry.async_on_unload(
- async_dispatcher_connect(
- hass,
- SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id),
- update_devices,
- )
- )
-
@callback
def add_tracked_entities(
hass: HomeAssistant,
- tado: TadoConnector,
+ coordinator: TadoMobileDeviceUpdateCoordinator,
async_add_entities: AddEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new tracker entities from Tado."""
_LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities")
new_tracked = []
- for device_key, device in tado.data["mobile_device"].items():
+ for device_key, device in coordinator.data["mobile_device"].items():
if device_key in tracked:
continue
_LOGGER.debug(
"Adding Tado device %s with deviceID %s", device["name"], device_key
)
- new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado))
+ new_tracked.append(
+ TadoDeviceTrackerEntity(device_key, device["name"], coordinator)
+ )
tracked.add(device_key)
async_add_entities(new_tracked)
-class TadoDeviceTrackerEntity(TrackerEntity):
+class TadoDeviceTrackerEntity(CoordinatorEntity[DataUpdateCoordinator], TrackerEntity):
"""A Tado Device Tracker entity."""
- _attr_should_poll = False
_attr_available = False
def __init__(
self,
device_id: str,
device_name: str,
- tado: TadoConnector,
+ coordinator: TadoMobileDeviceUpdateCoordinator,
) -> None:
"""Initialize a Tado Device Tracker entity."""
- super().__init__()
+ super().__init__(coordinator)
self._attr_unique_id = str(device_id)
self._device_id = device_id
self._device_name = device_name
- self._tado = tado
self._active = False
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self.update_state()
+ super()._handle_coordinator_update()
+
@callback
def update_state(self) -> None:
"""Update the Tado device."""
@@ -109,7 +110,7 @@ class TadoDeviceTrackerEntity(TrackerEntity):
self._device_name,
self._device_id,
)
- device = self._tado.data["mobile_device"][self._device_id]
+ device = self.coordinator.data["mobile_device"][self._device_id]
self._attr_available = False
_LOGGER.debug(
@@ -129,25 +130,6 @@ class TadoDeviceTrackerEntity(TrackerEntity):
else:
_LOGGER.debug("Tado device %s is not at home", device["name"])
- @callback
- def on_demand_update(self) -> None:
- """Update state on demand."""
- self.update_state()
- self.async_write_ha_state()
-
- async def async_added_to_hass(self) -> None:
- """Register state update callback."""
- _LOGGER.debug("Registering Tado device tracker entity")
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id),
- self.on_demand_update,
- )
- )
-
- self.update_state()
-
@property
def name(self) -> str:
"""Return the name of the device."""
diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py
index 6bb90ab849a..971b2863aba 100644
--- a/homeassistant/components/tado/entity.py
+++ b/homeassistant/components/tado/entity.py
@@ -1,21 +1,30 @@
"""Base class for Tado entity."""
+import logging
+
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import TadoConnector
from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE
+from .coordinator import TadoDataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
-class TadoDeviceEntity(Entity):
- """Base implementation for Tado device."""
+class TadoCoordinatorEntity(CoordinatorEntity[TadoDataUpdateCoordinator]):
+ """Base class for Tado entity."""
- _attr_should_poll = False
_attr_has_entity_name = True
- def __init__(self, device_info: dict[str, str]) -> None:
+
+class TadoDeviceEntity(TadoCoordinatorEntity):
+ """Base implementation for Tado device."""
+
+ def __init__(
+ self, device_info: dict[str, str], coordinator: TadoDataUpdateCoordinator
+ ) -> None:
"""Initialize a Tado device."""
- super().__init__()
+ super().__init__(coordinator)
self._device_info = device_info
self.device_name = device_info["serialNo"]
self.device_id = device_info["shortSerialNo"]
@@ -30,35 +39,35 @@ class TadoDeviceEntity(Entity):
)
-class TadoHomeEntity(Entity):
+class TadoHomeEntity(TadoCoordinatorEntity):
"""Base implementation for Tado home."""
- _attr_should_poll = False
- _attr_has_entity_name = True
-
- def __init__(self, tado: TadoConnector) -> None:
+ def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None:
"""Initialize a Tado home."""
- super().__init__()
- self.home_name = tado.home_name
- self.home_id = tado.home_id
+ super().__init__(coordinator)
+ self.home_name = coordinator.home_name
+ self.home_id = coordinator.home_id
self._attr_device_info = DeviceInfo(
configuration_url="https://app.tado.com",
- identifiers={(DOMAIN, str(tado.home_id))},
+ identifiers={(DOMAIN, str(coordinator.home_id))},
manufacturer=DEFAULT_NAME,
model=TADO_HOME,
- name=tado.home_name,
+ name=coordinator.home_name,
)
-class TadoZoneEntity(Entity):
+class TadoZoneEntity(TadoCoordinatorEntity):
"""Base implementation for Tado zone."""
- _attr_has_entity_name = True
- _attr_should_poll = False
-
- def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None:
+ def __init__(
+ self,
+ zone_name: str,
+ home_id: int,
+ zone_id: int,
+ coordinator: TadoDataUpdateCoordinator,
+ ) -> None:
"""Initialize a Tado zone."""
- super().__init__()
+ super().__init__(coordinator)
self.zone_name = zone_name
self.zone_id = zone_id
self._attr_device_info = DeviceInfo(
diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py
index 558aee164d0..571a757a3e8 100644
--- a/homeassistant/components/tado/helper.py
+++ b/homeassistant/components/tado/helper.py
@@ -5,26 +5,27 @@ from .const import (
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER,
)
-from .tado_connector import TadoConnector
+from .coordinator import TadoDataUpdateCoordinator
def decide_overlay_mode(
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
duration: int | None,
zone_id: int,
overlay_mode: str | None = None,
) -> str:
"""Return correct overlay mode based on the action and defaults."""
+
# If user gave duration then overlay mode needs to be timer
if duration:
return CONST_OVERLAY_TIMER
# If no duration or timer set to fallback setting
if overlay_mode is None:
- overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE
+ overlay_mode = coordinator.fallback or CONST_OVERLAY_TADO_MODE
# If default is Tado default then look it up
if overlay_mode == CONST_OVERLAY_TADO_DEFAULT:
overlay_mode = (
- tado.data["zone"][zone_id].default_overlay_termination_type
+ coordinator.data["zone"][zone_id].default_overlay_termination_type
or CONST_OVERLAY_TADO_MODE
)
@@ -32,18 +33,19 @@ def decide_overlay_mode(
def decide_duration(
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
duration: int | None,
zone_id: int,
overlay_mode: str | None = None,
) -> None | int:
"""Return correct duration based on the selected overlay mode/duration and tado config."""
+
# If we ended up with a timer but no duration, set a default duration
# If we ended up with a timer but no duration, set a default duration
if overlay_mode == CONST_OVERLAY_TIMER and duration is None:
duration = (
- int(tado.data["zone"][zone_id].default_overlay_termination_duration)
- if tado.data["zone"][zone_id].default_overlay_termination_duration
+ int(coordinator.data["zone"][zone_id].default_overlay_termination_duration)
+ if coordinator.data["zone"][zone_id].default_overlay_termination_duration
is not None
else 3600
)
@@ -53,6 +55,7 @@ def decide_duration(
def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]):
"""Return correct list of fan modes or None."""
+
supported_fanmodes = [
tado_to_ha_mapping.get(option)
for option in options
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
index b0c00c888b7..856a0c5402b 100644
--- a/homeassistant/components/tado/manifest.json
+++ b/homeassistant/components/tado/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "tado",
"name": "Tado",
- "codeowners": ["@chiefdragon", "@erwindouna"],
+ "codeowners": ["@erwindouna"],
"config_flow": true,
"dhcp": [
{
@@ -14,5 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["PyTado"],
- "requirements": ["python-tado==0.17.6"]
+ "requirements": ["python-tado==0.18.5"]
}
diff --git a/homeassistant/components/tado/models.py b/homeassistant/components/tado/models.py
new file mode 100644
index 00000000000..08bdaceaf03
--- /dev/null
+++ b/homeassistant/components/tado/models.py
@@ -0,0 +1,13 @@
+"""Models for use in Tado integration."""
+
+from dataclasses import dataclass
+
+from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator
+
+
+@dataclass
+class TadoData:
+ """Class to hold Tado data."""
+
+ coordinator: TadoDataUpdateCoordinator
+ mobile_coordinator: TadoMobileDeviceUpdateCoordinator
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
index 8bb13a02cd1..037b33574e7 100644
--- a/homeassistant/components/tado/sensor.py
+++ b/homeassistant/components/tado/sensor.py
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -24,13 +23,12 @@ from .const import (
CONDITIONS_MAP,
SENSOR_DATA_CATEGORY_GEOFENCE,
SENSOR_DATA_CATEGORY_WEATHER,
- SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
TYPE_HOT_WATER,
)
+from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoHomeEntity, TadoZoneEntity
-from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
@@ -197,7 +195,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado sensor platform."""
- tado = entry.runtime_data
+ tado = entry.runtime_data.coordinator
zones = tado.zones
entities: list[SensorEntity] = []
@@ -232,39 +230,22 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
entity_description: TadoSensorEntityDescription
def __init__(
- self, tado: TadoConnector, entity_description: TadoSensorEntityDescription
+ self,
+ coordinator: TadoDataUpdateCoordinator,
+ entity_description: TadoSensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
- super().__init__(tado)
- self._tado = tado
+ super().__init__(coordinator)
- self._attr_unique_id = f"{entity_description.key} {tado.home_id}"
-
- async def async_added_to_hass(self) -> None:
- """Register for sensor updates."""
-
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"),
- self._async_update_callback,
- )
- )
- self._async_update_home_data()
+ self._attr_unique_id = f"{entity_description.key} {coordinator.home_id}"
@callback
- def _async_update_callback(self) -> None:
- """Update and write state."""
- self._async_update_home_data()
- self.async_write_ha_state()
-
- @callback
- def _async_update_home_data(self) -> None:
- """Handle update callbacks."""
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
try:
- tado_weather_data = self._tado.data["weather"]
- tado_geofence_data = self._tado.data["geofence"]
+ tado_weather_data = self.coordinator.data["weather"]
+ tado_geofence_data = self.coordinator.data["geofence"]
except KeyError:
return
@@ -278,6 +259,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_sensor_data
)
+ super()._handle_coordinator_update()
class TadoZoneSensor(TadoZoneEntity, SensorEntity):
@@ -287,43 +269,24 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
def __init__(
self,
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
entity_description: TadoSensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
- self._tado = tado
- super().__init__(zone_name, tado.home_id, zone_id)
+ super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
- self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}"
-
- async def async_added_to_hass(self) -> None:
- """Register for sensor updates."""
-
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self._tado.home_id, "zone", self.zone_id
- ),
- self._async_update_callback,
- )
+ self._attr_unique_id = (
+ f"{entity_description.key} {zone_id} {coordinator.home_id}"
)
- self._async_update_zone_data()
@callback
- def _async_update_callback(self) -> None:
- """Update and write state."""
- self._async_update_zone_data()
- self.async_write_ha_state()
-
- @callback
- def _async_update_zone_data(self) -> None:
- """Handle update callbacks."""
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
try:
- tado_zone_data = self._tado.data["zone"][self.zone_id]
+ tado_zone_data = self.coordinator.data["zone"][self.zone_id]
except KeyError:
return
@@ -332,3 +295,4 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_zone_data
)
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py
index 89711808066..d931ea303e9 100644
--- a/homeassistant/components/tado/services.py
+++ b/homeassistant/components/tado/services.py
@@ -43,11 +43,8 @@ def setup_services(hass: HomeAssistant) -> None:
if entry is None:
raise ServiceValidationError("Config entry not found")
- tadoconnector = entry.runtime_data
-
- response: dict = await hass.async_add_executor_job(
- tadoconnector.set_meter_reading, call.data[CONF_READING]
- )
+ coordinator = entry.runtime_data.coordinator
+ response: dict = await coordinator.set_meter_reading(call.data[CONF_READING])
if ATTR_MESSAGE in response:
raise HomeAssistantError(response[ATTR_MESSAGE])
diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json
index 8124570f9c9..f1550517457 100644
--- a/homeassistant/components/tado/strings.json
+++ b/homeassistant/components/tado/strings.json
@@ -14,7 +14,7 @@
},
"reconfigure": {
"title": "Reconfigure your Tado",
- "description": "Reconfigure the entry, for your account: `{username}`.",
+ "description": "Reconfigure the entry for your account: `{username}`.",
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
@@ -25,7 +25,7 @@
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
- "no_homes": "There are no homes linked to this tado account.",
+ "no_homes": "There are no homes linked to this Tado account.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
@@ -33,7 +33,7 @@
"options": {
"step": {
"init": {
- "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Dont change until you cancel; TADO_DEFAULT:= Change based on your setting in Tado App).",
+ "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Don't change until you cancel; TADO_DEFAULT:= Change based on your setting in the Tado app).",
"data": {
"fallback": "Choose fallback mode."
},
@@ -102,11 +102,11 @@
},
"time_period": {
"name": "Time period",
- "description": "Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay."
+ "description": "Choose this or 'Overlay'. Set the time period for the change if you want to be specific."
},
"requested_overlay": {
"name": "Overlay",
- "description": "Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting."
+ "description": "Choose this or 'Time period'. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on Tado app setting."
}
}
},
@@ -135,12 +135,12 @@
}
},
"add_meter_reading": {
- "name": "Add meter readings",
- "description": "Add meter readings to Tado Energy IQ.",
+ "name": "Add meter reading",
+ "description": "Adds a meter reading to Tado Energy IQ.",
"fields": {
"config_entry": {
"name": "Config Entry",
- "description": "Config entry to add meter readings to."
+ "description": "Config entry to add meter reading to."
},
"reading": {
"name": "Reading",
@@ -151,8 +151,8 @@
},
"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. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)."
+ "title": "Tado water heater entities now support fallback options",
+ "description": "Due to added support for water heaters entities, these entities may use a different overlay. Please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)."
}
}
}
diff --git a/homeassistant/components/tado/tado_connector.py b/homeassistant/components/tado/tado_connector.py
deleted file mode 100644
index 5ed53675153..00000000000
--- a/homeassistant/components/tado/tado_connector.py
+++ /dev/null
@@ -1,332 +0,0 @@
-"""Tado Connector a class to store the data as an object."""
-
-from datetime import datetime, timedelta
-import logging
-from typing import Any
-
-from PyTado.interface import Tado
-from requests import RequestException
-
-from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.dispatcher import dispatcher_send
-from homeassistant.util import Throttle
-
-from .const import (
- INSIDE_TEMPERATURE_MEASUREMENT,
- PRESET_AUTO,
- SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED,
- SIGNAL_TADO_UPDATE_RECEIVED,
- TEMP_OFFSET,
-)
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4)
-SCAN_INTERVAL = timedelta(minutes=5)
-SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
-
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TadoConnector:
- """An object to store the Tado data."""
-
- def __init__(
- self, hass: HomeAssistant, username: str, password: str, fallback: str
- ) -> None:
- """Initialize Tado Connector."""
- self.hass = hass
- self._username = username
- self._password = password
- self._fallback = fallback
-
- self.home_id: int = 0
- self.home_name = None
- self.tado = None
- self.zones: list[dict[Any, Any]] = []
- self.devices: list[dict[Any, Any]] = []
- self.data: dict[str, dict] = {
- "device": {},
- "mobile_device": {},
- "weather": {},
- "geofence": {},
- "zone": {},
- }
-
- @property
- def fallback(self):
- """Return fallback flag to Smart Schedule."""
- return self._fallback
-
- def setup(self):
- """Connect to Tado and fetch the zones."""
- self.tado = Tado(self._username, self._password)
- # Load zones and devices
- self.zones = self.tado.get_zones()
- self.devices = self.tado.get_devices()
- tado_home = self.tado.get_me()["homes"][0]
- self.home_id = tado_home["id"]
- self.home_name = tado_home["name"]
-
- def get_mobile_devices(self):
- """Return the Tado mobile devices."""
- return self.tado.get_mobile_devices()
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Update the registered zones."""
- self.update_devices()
- self.update_mobile_devices()
- self.update_zones()
- self.update_home()
-
- def update_mobile_devices(self) -> None:
- """Update the mobile devices."""
- try:
- mobile_devices = self.get_mobile_devices()
- except RuntimeError:
- _LOGGER.error("Unable to connect to Tado while updating mobile devices")
- return
-
- if not mobile_devices:
- _LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id)
- return
-
- # Errors are planned to be converted to exceptions
- # in PyTado library, so this can be removed
- if isinstance(mobile_devices, dict) and mobile_devices.get("errors"):
- _LOGGER.error(
- "Error for home ID %s while updating mobile devices: %s",
- self.home_id,
- mobile_devices["errors"],
- )
- return
-
- for mobile_device in mobile_devices:
- self.data["mobile_device"][mobile_device["id"]] = mobile_device
- _LOGGER.debug(
- "Dispatching update to %s mobile device: %s",
- self.home_id,
- mobile_device,
- )
-
- dispatcher_send(
- self.hass,
- SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id),
- )
-
- def update_devices(self):
- """Update the device data from Tado."""
- try:
- devices = self.tado.get_devices()
- except RuntimeError:
- _LOGGER.error("Unable to connect to Tado while updating devices")
- return
-
- if not devices:
- _LOGGER.debug("No linked devices found for home ID %s", self.home_id)
- return
-
- # Errors are planned to be converted to exceptions
- # in PyTado library, so this can be removed
- if isinstance(devices, dict) and devices.get("errors"):
- _LOGGER.error(
- "Error for home ID %s while updating devices: %s",
- self.home_id,
- devices["errors"],
- )
- return
-
- for device in devices:
- device_short_serial_no = device["shortSerialNo"]
- _LOGGER.debug("Updating device %s", device_short_serial_no)
- try:
- if (
- INSIDE_TEMPERATURE_MEASUREMENT
- in device["characteristics"]["capabilities"]
- ):
- device[TEMP_OFFSET] = self.tado.get_device_info(
- device_short_serial_no, TEMP_OFFSET
- )
- except RuntimeError:
- _LOGGER.error(
- "Unable to connect to Tado while updating device %s",
- device_short_serial_no,
- )
- return
-
- self.data["device"][device_short_serial_no] = device
-
- _LOGGER.debug(
- "Dispatching update to %s device %s: %s",
- self.home_id,
- device_short_serial_no,
- device,
- )
- dispatcher_send(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self.home_id, "device", device_short_serial_no
- ),
- )
-
- def update_zones(self):
- """Update the zone data from Tado."""
- try:
- zone_states = self.tado.get_zone_states()["zoneStates"]
- except RuntimeError:
- _LOGGER.error("Unable to connect to Tado while updating zones")
- return
-
- for zone in zone_states:
- self.update_zone(int(zone))
-
- def update_zone(self, zone_id):
- """Update the internal data from Tado."""
- _LOGGER.debug("Updating zone %s", zone_id)
- try:
- data = self.tado.get_zone_state(zone_id)
- except RuntimeError:
- _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id)
- return
-
- self.data["zone"][zone_id] = data
-
- _LOGGER.debug(
- "Dispatching update to %s zone %s: %s",
- self.home_id,
- zone_id,
- data,
- )
- dispatcher_send(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id),
- )
-
- def update_home(self):
- """Update the home data from Tado."""
- try:
- self.data["weather"] = self.tado.get_weather()
- self.data["geofence"] = self.tado.get_home_state()
- dispatcher_send(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"),
- )
- except RuntimeError:
- _LOGGER.error(
- "Unable to connect to Tado while updating weather and geofence data"
- )
- return
-
- def get_capabilities(self, zone_id):
- """Return the capabilities of the devices."""
- return self.tado.get_capabilities(zone_id)
-
- def get_auto_geofencing_supported(self):
- """Return whether the Tado Home supports auto geofencing."""
- return self.tado.get_auto_geofencing_supported()
-
- def reset_zone_overlay(self, zone_id):
- """Reset the zone back to the default operation."""
- self.tado.reset_zone_overlay(zone_id)
- self.update_zone(zone_id)
-
- def set_presence(
- self,
- presence=PRESET_HOME,
- ):
- """Set the presence to home, away or auto."""
- if presence == PRESET_AWAY:
- self.tado.set_away()
- elif presence == PRESET_HOME:
- self.tado.set_home()
- elif presence == PRESET_AUTO:
- self.tado.set_auto()
-
- # Update everything when changing modes
- self.update_zones()
- self.update_home()
-
- def set_zone_overlay(
- self,
- zone_id=None,
- overlay_mode=None,
- temperature=None,
- duration=None,
- device_type="HEATING",
- mode=None,
- fan_speed=None,
- swing=None,
- fan_level=None,
- vertical_swing=None,
- horizontal_swing=None,
- ):
- """Set a zone overlay."""
- _LOGGER.debug(
- (
- "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s,"
- " type=%s, mode=%s fan_speed=%s swing=%s fan_level=%s vertical_swing=%s horizontal_swing=%s"
- ),
- zone_id,
- overlay_mode,
- temperature,
- duration,
- device_type,
- mode,
- fan_speed,
- swing,
- fan_level,
- vertical_swing,
- horizontal_swing,
- )
-
- try:
- self.tado.set_zone_overlay(
- zone_id,
- overlay_mode,
- temperature,
- duration,
- device_type,
- "ON",
- mode,
- fan_speed=fan_speed,
- swing=swing,
- fan_level=fan_level,
- vertical_swing=vertical_swing,
- horizontal_swing=horizontal_swing,
- )
-
- except RequestException as exc:
- _LOGGER.error("Could not set zone overlay: %s", exc)
-
- self.update_zone(zone_id)
-
- def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"):
- """Set a zone to off."""
- try:
- self.tado.set_zone_overlay(
- zone_id, overlay_mode, None, None, device_type, "OFF"
- )
- except RequestException as exc:
- _LOGGER.error("Could not set zone overlay: %s", exc)
-
- self.update_zone(zone_id)
-
- def set_temperature_offset(self, device_id, offset):
- """Set temperature offset of device."""
- try:
- self.tado.set_temp_offset(device_id, offset)
- except RequestException as exc:
- _LOGGER.error("Could not set temperature offset: %s", exc)
-
- def set_meter_reading(self, reading: int) -> dict[str, Any]:
- """Send meter reading to Tado."""
- dt: str = datetime.now().strftime("%Y-%m-%d")
- if self.tado is None:
- raise HomeAssistantError("Tado client is not initialized")
-
- try:
- return self.tado.set_eiq_meter_readings(date=dt, reading=reading)
- except RequestException as exc:
- raise HomeAssistantError("Could not set meter reading") from exc
diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py
index 6c964cfaddd..02fbb3f5e23 100644
--- a/homeassistant/components/tado/water_heater.py
+++ b/homeassistant/components/tado/water_heater.py
@@ -12,7 +12,6 @@ from homeassistant.components.water_heater import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
@@ -26,13 +25,12 @@ from .const import (
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER,
- SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_HOT_WATER,
)
+from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoZoneEntity
from .helper import decide_duration, decide_overlay_mode
from .repairs import manage_water_heater_fallback_issue
-from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
@@ -67,8 +65,9 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado water heater platform."""
- tado = entry.runtime_data
- entities = await hass.async_add_executor_job(_generate_entities, tado)
+ data = entry.runtime_data
+ coordinator = data.coordinator
+ entities = await _generate_entities(coordinator)
platform = entity_platform.async_get_current_platform()
@@ -83,27 +82,29 @@ async def async_setup_entry(
manage_water_heater_fallback_issue(
hass=hass,
water_heater_names=[e.zone_name for e in entities],
- integration_overlay_fallback=tado.fallback,
+ integration_overlay_fallback=coordinator.fallback,
)
-def _generate_entities(tado: TadoConnector) -> list:
+async def _generate_entities(coordinator: TadoDataUpdateCoordinator) -> list:
"""Create all water heater entities."""
entities = []
- for zone in tado.zones:
+ for zone in coordinator.zones:
if zone["type"] == TYPE_HOT_WATER:
- entity = create_water_heater_entity(
- tado, zone["name"], zone["id"], str(zone["name"])
+ entity = await create_water_heater_entity(
+ coordinator, zone["name"], zone["id"], str(zone["name"])
)
entities.append(entity)
return entities
-def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zone: str):
+async def create_water_heater_entity(
+ coordinator: TadoDataUpdateCoordinator, name: str, zone_id: int, zone: str
+):
"""Create a Tado water heater device."""
- capabilities = tado.get_capabilities(zone_id)
+ capabilities = await coordinator.get_capabilities(zone_id)
supports_temperature_control = capabilities["canSetTemperature"]
@@ -116,7 +117,7 @@ def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zon
max_temp = None
return TadoWaterHeater(
- tado,
+ coordinator,
name,
zone_id,
supports_temperature_control,
@@ -134,7 +135,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
def __init__(
self,
- tado: TadoConnector,
+ coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
supports_temperature_control: bool,
@@ -142,11 +143,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
max_temp,
) -> None:
"""Initialize of Tado water heater entity."""
- self._tado = tado
- super().__init__(zone_name, tado.home_id, zone_id)
+ super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self.zone_id = zone_id
- self._attr_unique_id = f"{zone_id} {tado.home_id}"
+ self._attr_unique_id = f"{zone_id} {coordinator.home_id}"
self._device_is_active = False
@@ -164,19 +164,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._tado_zone_data: Any = None
- async def async_added_to_hass(self) -> None:
- """Register for sensor updates."""
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self._tado.home_id, "zone", self.zone_id
- ),
- self._async_update_callback,
- )
- )
self._async_update_data()
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._async_update_data()
+ super()._handle_coordinator_update()
+
@property
def current_operation(self) -> str | None:
"""Return current readable operation mode."""
@@ -202,7 +197,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
"""Return the maximum temperature."""
return self._max_temperature
- def set_operation_mode(self, operation_mode: str) -> None:
+ async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
mode = None
@@ -213,18 +208,20 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
elif operation_mode == MODE_HEAT:
mode = CONST_MODE_HEAT
- self._control_heater(hvac_mode=mode)
+ await self._control_heater(hvac_mode=mode)
+ await self.coordinator.async_request_refresh()
- def set_timer(self, time_period: int, temperature: float | None = None):
+ async def set_timer(self, time_period: int, temperature: float | None = None):
"""Set the timer on the entity, and temperature if supported."""
if not self._supports_temperature_control and temperature is not None:
temperature = None
- self._control_heater(
+ await self._control_heater(
hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period
)
+ await self.coordinator.async_request_refresh()
- def set_temperature(self, **kwargs: Any) -> None:
+ async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if not self._supports_temperature_control or temperature is None:
@@ -235,10 +232,11 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
- self._control_heater(target_temp=temperature)
+ await self._control_heater(target_temp=temperature)
return
- self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT)
+ await self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT)
+ await self.coordinator.async_request_refresh()
@callback
def _async_update_callback(self) -> None:
@@ -250,10 +248,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
def _async_update_data(self) -> None:
"""Load tado data."""
_LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id)
- self._tado_zone_data = self._tado.data["zone"][self.zone_id]
+ self._tado_zone_data = self.coordinator.data["zone"][self.zone_id]
self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
- def _control_heater(
+ async def _control_heater(
self,
hvac_mode: str | None = None,
target_temp: float | None = None,
@@ -276,23 +274,26 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self.zone_name,
self.zone_id,
)
- self._tado.reset_zone_overlay(self.zone_id)
+ await self.coordinator.reset_zone_overlay(self.zone_id)
+ await self.coordinator.async_request_refresh()
return
if self._current_tado_hvac_mode == CONST_MODE_OFF:
_LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id
)
- self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER)
+ await self.coordinator.set_zone_off(
+ self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER
+ )
return
overlay_mode = decide_overlay_mode(
- tado=self._tado,
+ coordinator=self.coordinator,
duration=duration,
zone_id=self.zone_id,
)
duration = decide_duration(
- tado=self._tado,
+ coordinator=self.coordinator,
duration=duration,
zone_id=self.zone_id,
overlay_mode=overlay_mode,
@@ -304,7 +305,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self.zone_id,
self._target_temp,
)
- self._tado.set_zone_overlay(
+ await self.coordinator.set_zone_overlay(
zone_id=self.zone_id,
overlay_mode=overlay_mode,
temperature=self._target_temp,
diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py
index 47c1d14ce60..8d42596d3db 100644
--- a/homeassistant/components/tag/__init__.py
+++ b/homeassistant/components/tag/__init__.py
@@ -13,14 +13,16 @@ from homeassistant.components import websocket_api
from homeassistant.const import CONF_ID, CONF_NAME
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import collection, entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ collection,
+ config_validation as cv,
+ entity_registry as er,
+)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.hass_dict import HassKey
from .const import DEFAULT_NAME, DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, LOGGER, TAG_ID
diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py
index 48fe2d23727..daf0fbd32b7 100644
--- a/homeassistant/components/tailwind/config_flow.py
+++ b/homeassistant/components/tailwind/config_flow.py
@@ -15,8 +15,6 @@ from gotailwind import (
)
import voluptuous as vol
-from homeassistant.components import zeroconf
-from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.data_entry_flow import AbortFlow
@@ -27,6 +25,8 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
@@ -83,7 +83,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery of a Tailwind device."""
if not (device_id := discovery_info.properties.get("device_id")):
diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py
index ec13dc7bd1f..dafb46e6f63 100644
--- a/homeassistant/components/tailwind/entity.py
+++ b/homeassistant/components/tailwind/entity.py
@@ -58,7 +58,7 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")},
via_device=(DOMAIN, coordinator.data.device_id),
- name=f"Door {coordinator.data.doors[door_id].index+1}",
+ name=f"Door {coordinator.data.doors[door_id].index + 1}",
manufacturer="Tailwind",
model=coordinator.data.product,
sw_version=coordinator.data.firmware_version,
diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py
index 72b19470f45..a58c801c403 100644
--- a/homeassistant/components/tami4/config_flow.py
+++ b/homeassistant/components/tami4/config_flow.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN
diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py
index 6d4327a1d06..e9377e346d4 100644
--- a/homeassistant/components/tank_utility/sensor.py
+++ b/homeassistant/components/tank_utility/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, PERCENTAGE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py
index a500549a648..b2b60db9675 100644
--- a/homeassistant/components/tankerkoenig/__init__.py
+++ b/homeassistant/components/tankerkoenig/__init__.py
@@ -17,11 +17,7 @@ async def async_setup_entry(
"""Set a tankerkoenig configuration entry up."""
hass.data.setdefault(DOMAIN, {})
- coordinator = TankerkoenigDataUpdateCoordinator(
- hass,
- name=entry.unique_id or DOMAIN,
- update_interval=DEFAULT_SCAN_INTERVAL,
- )
+ coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL)
await coordinator.async_setup()
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py
index 509f293665d..8796ae46ab7 100644
--- a/homeassistant/components/tankerkoenig/config_flow.py
+++ b/homeassistant/components/tankerkoenig/config_flow.py
@@ -31,8 +31,8 @@ from homeassistant.const import (
UnitOfLength,
)
from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
LocationSelector,
NumberSelector,
diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py
index 17e94f62fe9..1f73d0577b3 100644
--- a/homeassistant/components/tankerkoenig/coordinator.py
+++ b/homeassistant/components/tankerkoenig/coordinator.py
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_FUEL_TYPES, CONF_STATIONS
+from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -39,7 +39,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf
def __init__(
self,
hass: HomeAssistant,
- name: str,
+ config_entry: TankerkoenigConfigEntry,
update_interval: int,
) -> None:
"""Initialize the data object."""
@@ -47,7 +47,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf
super().__init__(
hass=hass,
logger=_LOGGER,
- name=name,
+ config_entry=config_entry,
+ name=config_entry.unique_id or DOMAIN,
update_interval=timedelta(minutes=update_interval),
)
diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py
index 0eb612bdc8e..beba9c91538 100644
--- a/homeassistant/components/tapsaff/binary_sensor.py
+++ b/homeassistant/components/tapsaff/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_LOCATION, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py
index 8a4b501af05..22cdf1a5ff0 100644
--- a/homeassistant/components/tasmota/binary_sensor.py
+++ b/homeassistant/components/tasmota/binary_sensor.py
@@ -14,9 +14,9 @@ from homeassistant.components import binary_sensor
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import event as evt
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.event as evt
from .const import DATA_REMOVE_DISCOVER_COMPONENT
from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py
index 9deb846f8e2..5b1adc839ac 100644
--- a/homeassistant/components/tasmota/config_flow.py
+++ b/homeassistant/components/tasmota/config_flow.py
@@ -66,8 +66,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
bad_prefix = False
prefix = user_input[CONF_DISCOVERY_PREFIX]
- if prefix.endswith("/#"):
- prefix = prefix[:-2]
+ prefix = prefix.removesuffix("/#")
try:
valid_subscribe_topic(f"{prefix}/#")
except vol.Invalid:
diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py
index a89cd999ddd..1263effa96b 100644
--- a/homeassistant/components/tcp/common.py
+++ b/homeassistant/components/tcp/common.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONF_BUFFER_SIZE,
diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py
index 1ecefe6f85c..f231e206c96 100644
--- a/homeassistant/components/technove/binary_sensor.py
+++ b/homeassistant/components/technove/binary_sensor.py
@@ -4,28 +4,19 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING
from technove import Station as TechnoVEStation
from homeassistant.components.binary_sensor import (
- DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
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,
- async_delete_issue,
-)
from . import TechnoVEConfigEntry
-from .const import DOMAIN
from .coordinator import TechnoVEDataUpdateCoordinator
from .entity import TechnoVEEntity
@@ -34,7 +25,6 @@ from .entity import TechnoVEEntity
class TechnoVEBinarySensorDescription(BinarySensorEntityDescription):
"""Describes TechnoVE binary sensor entity."""
- deprecated_version: str | None = None
value_fn: Callable[[TechnoVEStation], bool | None]
@@ -57,15 +47,6 @@ BINARY_SENSORS = [
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda station: station.info.is_battery_protected,
),
- TechnoVEBinarySensorDescription(
- key="is_session_active",
- entity_category=EntityCategory.DIAGNOSTIC,
- device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
- value_fn=lambda station: station.info.is_session_active,
- deprecated_version="2025.2.0",
- # Disabled by default, as this entity is deprecated
- entity_registry_enabled_default=False,
- ),
TechnoVEBinarySensorDescription(
key="is_static_ip",
translation_key="is_static_ip",
@@ -113,34 +94,3 @@ class TechnoVEBinarySensorEntity(TechnoVEEntity, BinarySensorEntity):
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
-
- async def async_added_to_hass(self) -> None:
- """Raise issue when entity is registered and was not disabled."""
- if TYPE_CHECKING:
- assert self.unique_id
- if entity_id := er.async_get(self.hass).async_get_entity_id(
- BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id
- ):
- if self.enabled and self.entity_description.deprecated_version:
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_entity_{self.entity_description.key}",
- breaks_in_ha_version=self.entity_description.deprecated_version,
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_entity_{self.entity_description.key}",
- translation_placeholders={
- "sensor_name": self.name
- if isinstance(self.name, str)
- else entity_id,
- "entity": entity_id,
- },
- )
- else:
- async_delete_issue(
- self.hass,
- DOMAIN,
- f"deprecated_entity_{self.entity_description.key}",
- )
- await super().async_added_to_hass()
diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py
index 0e4f026ba5c..7ad9829b631 100644
--- a/homeassistant/components/technove/config_flow.py
+++ b/homeassistant/components/technove/config_flow.py
@@ -5,10 +5,11 @@ from typing import Any
from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError
import voluptuous as vol
-from homeassistant.components import onboarding, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -49,7 +50,7 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
# Abort quick if the device with provided mac is already configured
diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json
index 7175b7c2de5..9976f0b3c59 100644
--- a/homeassistant/components/technove/strings.json
+++ b/homeassistant/components/technove/strings.json
@@ -90,11 +90,5 @@
"set_charging_enabled_on_auto_charge": {
"message": "Cannot enable or disable charging when auto-charge is enabled. Try disabling auto-charge first."
}
- },
- "issues": {
- "deprecated_entity_is_session_active": {
- "title": "The TechnoVE {sensor_name} binary sensor is deprecated",
- "description": "`{entity}` is deprecated.\nPlease update your automations and scripts to replace the binary sensor entity with the newly added switch entity.\nWhen you are done migrating you can disable `{entity}`."
- }
}
}
diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py
index 94d3f0b6831..4f167619f04 100644
--- a/homeassistant/components/tedee/binary_sensor.py
+++ b/homeassistant/components/tedee/binary_sensor.py
@@ -69,20 +69,15 @@ async def async_setup_entry(
"""Set up the Tedee sensor entity."""
coordinator = entry.runtime_data
- async_add_entities(
- TedeeBinarySensorEntity(lock, coordinator, entity_description)
- for lock in coordinator.data.values()
- for entity_description in ENTITIES
- )
-
- def _async_add_new_lock(lock_id: int) -> None:
- lock = coordinator.data[lock_id]
+ def _async_add_new_lock(locks: list[TedeeLock]) -> None:
async_add_entities(
TedeeBinarySensorEntity(lock, coordinator, entity_description)
for entity_description in ENTITIES
+ for lock in locks
)
coordinator.new_lock_callbacks.append(_async_add_new_lock)
+ _async_add_new_lock(list(coordinator.data.values()))
class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity):
diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py
index 4012b6d07c5..fec59d1c596 100644
--- a/homeassistant/components/tedee/coordinator.py
+++ b/homeassistant/components/tedee/coordinator.py
@@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
@@ -60,7 +60,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
self._next_get_locks = time.time()
self._locks_last_update: set[int] = set()
- self.new_lock_callbacks: list[Callable[[int], None]] = []
+ self.new_lock_callbacks: list[Callable[[list[TedeeLock]], None]] = []
self.tedee_webhook_id: int | None = None
async def _async_setup(self) -> None:
@@ -158,8 +158,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
# add new locks
if new_locks := current_locks - self._locks_last_update:
_LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks)))
- for lock_id in new_locks:
- for callback in self.new_lock_callbacks:
- callback(lock_id)
+ for callback in self.new_lock_callbacks:
+ callback([self.data[lock_id] for lock_id in new_locks])
self._locks_last_update = current_locks
diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py
index 38df85a9cdb..482cd039a98 100644
--- a/homeassistant/components/tedee/lock.py
+++ b/homeassistant/components/tedee/lock.py
@@ -24,23 +24,18 @@ async def async_setup_entry(
"""Set up the Tedee lock entity."""
coordinator = entry.runtime_data
- entities: list[TedeeLockEntity] = []
- for lock in coordinator.data.values():
- if lock.is_enabled_pullspring:
- entities.append(TedeeLockWithLatchEntity(lock, coordinator))
- else:
- entities.append(TedeeLockEntity(lock, coordinator))
-
- def _async_add_new_lock(lock_id: int) -> None:
- lock = coordinator.data[lock_id]
- if lock.is_enabled_pullspring:
- async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)])
- else:
- async_add_entities([TedeeLockEntity(lock, coordinator)])
+ def _async_add_new_lock(locks: list[TedeeLock]) -> None:
+ entities: list[TedeeLockEntity] = []
+ for lock in locks:
+ if lock.is_enabled_pullspring:
+ entities.append(TedeeLockWithLatchEntity(lock, coordinator))
+ else:
+ entities.append(TedeeLockEntity(lock, coordinator))
+ async_add_entities(entities)
coordinator.new_lock_callbacks.append(_async_add_new_lock)
- async_add_entities(entities)
+ _async_add_new_lock(list(coordinator.data.values()))
class TedeeLockEntity(TedeeEntity, LockEntity):
diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py
index d61e7360dc4..828793b4458 100644
--- a/homeassistant/components/tedee/sensor.py
+++ b/homeassistant/components/tedee/sensor.py
@@ -58,20 +58,15 @@ async def async_setup_entry(
"""Set up the Tedee sensor entity."""
coordinator = entry.runtime_data
- async_add_entities(
- TedeeSensorEntity(lock, coordinator, entity_description)
- for lock in coordinator.data.values()
- for entity_description in ENTITIES
- )
-
- def _async_add_new_lock(lock_id: int) -> None:
- lock = coordinator.data[lock_id]
+ def _async_add_new_lock(locks: list[TedeeLock]) -> None:
async_add_entities(
TedeeSensorEntity(lock, coordinator, entity_description)
for entity_description in ENTITIES
+ for lock in locks
)
coordinator.new_lock_callbacks.append(_async_add_new_lock)
+ _async_add_new_lock(list(coordinator.data.values()))
class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity):
diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json
index 78cacd706d3..c7204b6d2a9 100644
--- a/homeassistant/components/tedee/strings.json
+++ b/homeassistant/components/tedee/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "title": "Setup your tedee locks",
+ "title": "Set up your tedee locks",
"data": {
"local_access_token": "Local access token",
"host": "[%key:common::config_flow::data::host%]"
@@ -14,7 +14,7 @@
},
"reauth_confirm": {
"title": "Update of access key required",
- "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.",
+ "description": "Tedee needs an updated access key because the existing one is invalid or might have expired.",
"data": {
"local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]"
},
@@ -23,7 +23,7 @@
}
},
"reconfigure": {
- "title": "Reconfigure Tedee",
+ "title": "Reconfigure tedee",
"description": "Update the settings of this integration.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index b9a032d7f28..f744265e1c2 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -36,7 +36,13 @@ from homeassistant.const import (
HTTP_BEARER_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
)
-from homeassistant.core import Context, HomeAssistant, ServiceCall
+from homeassistant.core import (
+ Context,
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_loaded_integration
@@ -398,15 +404,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, bot, p_config.get(CONF_ALLOWED_CHAT_IDS), p_config.get(ATTR_PARSER)
)
- async def async_send_telegram_message(service: ServiceCall) -> None:
+ async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse:
"""Handle sending Telegram Bot message service calls."""
msgtype = service.service
kwargs = dict(service.data)
_LOGGER.debug("New telegram message %s: %s", msgtype, kwargs)
+ messages = None
if msgtype == SERVICE_SEND_MESSAGE:
- await notify_service.send_message(context=service.context, **kwargs)
+ messages = await notify_service.send_message(
+ context=service.context, **kwargs
+ )
elif msgtype in [
SERVICE_SEND_PHOTO,
SERVICE_SEND_ANIMATION,
@@ -414,13 +423,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_SEND_VOICE,
SERVICE_SEND_DOCUMENT,
]:
- await notify_service.send_file(msgtype, context=service.context, **kwargs)
+ messages = await notify_service.send_file(
+ msgtype, context=service.context, **kwargs
+ )
elif msgtype == SERVICE_SEND_STICKER:
- await notify_service.send_sticker(context=service.context, **kwargs)
+ messages = await notify_service.send_sticker(
+ context=service.context, **kwargs
+ )
elif msgtype == SERVICE_SEND_LOCATION:
- await notify_service.send_location(context=service.context, **kwargs)
+ messages = await notify_service.send_location(
+ context=service.context, **kwargs
+ )
elif msgtype == SERVICE_SEND_POLL:
- await notify_service.send_poll(context=service.context, **kwargs)
+ messages = await notify_service.send_poll(context=service.context, **kwargs)
elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY:
await notify_service.answer_callback_query(
context=service.context, **kwargs
@@ -432,10 +447,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
msgtype, context=service.context, **kwargs
)
+ if service.return_response and messages:
+ return {
+ "chats": [
+ {"chat_id": cid, "message_id": mid} for cid, mid in messages.items()
+ ]
+ }
+ return None
+
# Register notification services
for service_notif, schema in SERVICE_MAP.items():
+ supports_response = SupportsResponse.NONE
+
+ if service_notif in [
+ SERVICE_SEND_MESSAGE,
+ SERVICE_SEND_PHOTO,
+ SERVICE_SEND_ANIMATION,
+ SERVICE_SEND_VIDEO,
+ SERVICE_SEND_VOICE,
+ SERVICE_SEND_DOCUMENT,
+ SERVICE_SEND_STICKER,
+ SERVICE_SEND_LOCATION,
+ SERVICE_SEND_POLL,
+ ]:
+ supports_response = SupportsResponse.OPTIONAL
+
hass.services.async_register(
- DOMAIN, service_notif, async_send_telegram_message, schema=schema
+ DOMAIN,
+ service_notif,
+ async_send_telegram_message,
+ schema=schema,
+ supports_response=supports_response,
)
return True
@@ -694,9 +736,10 @@ class TelegramNotificationService:
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
+ msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params)
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_message,
"Error sending message",
params[ATTR_MESSAGE_TAG],
@@ -711,6 +754,8 @@ class TelegramNotificationService:
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
+ msg_ids[chat_id] = msg.id
+ return msg_ids
async def delete_message(self, chat_id=None, context=None, **kwargs):
"""Delete a previously sent message."""
@@ -829,12 +874,13 @@ class TelegramNotificationService:
),
)
+ msg_ids = {}
if file_content:
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug("Sending file to chat ID %s", chat_id)
if file_type == SERVICE_SEND_PHOTO:
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_photo,
"Error sending photo",
params[ATTR_MESSAGE_TAG],
@@ -851,7 +897,7 @@ class TelegramNotificationService:
)
elif file_type == SERVICE_SEND_STICKER:
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_sticker,
"Error sending sticker",
params[ATTR_MESSAGE_TAG],
@@ -866,7 +912,7 @@ class TelegramNotificationService:
)
elif file_type == SERVICE_SEND_VIDEO:
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_video,
"Error sending video",
params[ATTR_MESSAGE_TAG],
@@ -882,7 +928,7 @@ class TelegramNotificationService:
context=context,
)
elif file_type == SERVICE_SEND_DOCUMENT:
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_document,
"Error sending document",
params[ATTR_MESSAGE_TAG],
@@ -898,7 +944,7 @@ class TelegramNotificationService:
context=context,
)
elif file_type == SERVICE_SEND_VOICE:
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_voice,
"Error sending voice",
params[ATTR_MESSAGE_TAG],
@@ -913,7 +959,7 @@ class TelegramNotificationService:
context=context,
)
elif file_type == SERVICE_SEND_ANIMATION:
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_animation,
"Error sending animation",
params[ATTR_MESSAGE_TAG],
@@ -929,17 +975,22 @@ class TelegramNotificationService:
context=context,
)
+ msg_ids[chat_id] = msg.id
file_content.seek(0)
else:
_LOGGER.error("Can't send file with kwargs: %s", kwargs)
- async def send_sticker(self, target=None, context=None, **kwargs):
+ return msg_ids
+
+ async def send_sticker(self, target=None, context=None, **kwargs) -> dict:
"""Send a sticker from a telegram sticker pack."""
params = self._get_msg_kwargs(kwargs)
stickerid = kwargs.get(ATTR_STICKER_ID)
+
+ msg_ids = {}
if stickerid:
for chat_id in self._get_target_chat_ids(target):
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_sticker,
"Error sending sticker",
params[ATTR_MESSAGE_TAG],
@@ -952,8 +1003,9 @@ class TelegramNotificationService:
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
- else:
- await self.send_file(SERVICE_SEND_STICKER, target, **kwargs)
+ msg_ids[chat_id] = msg.id
+ return msg_ids
+ return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs)
async def send_location(
self, latitude, longitude, target=None, context=None, **kwargs
@@ -962,11 +1014,12 @@ class TelegramNotificationService:
latitude = float(latitude)
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
+ msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug(
"Send location %s/%s to chat ID %s", latitude, longitude, chat_id
)
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_location,
"Error sending location",
params[ATTR_MESSAGE_TAG],
@@ -979,6 +1032,8 @@ class TelegramNotificationService:
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
+ msg_ids[chat_id] = msg.id
+ return msg_ids
async def send_poll(
self,
@@ -993,9 +1048,10 @@ class TelegramNotificationService:
"""Send a poll."""
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
+ msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id)
- await self._send_msg(
+ msg = await self._send_msg(
self.bot.send_poll,
"Error sending poll",
params[ATTR_MESSAGE_TAG],
@@ -1011,6 +1067,8 @@ class TelegramNotificationService:
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
+ msg_ids[chat_id] = msg.id
+ return msg_ids
async def leave_chat(self, chat_id=None, context=None):
"""Remove bot from chat."""
@@ -1070,6 +1128,7 @@ class BaseTelegramBotEntity:
ATTR_MSGID: message.message_id,
ATTR_CHAT_ID: message.chat.id,
ATTR_DATE: message.date,
+ ATTR_MESSAGE_THREAD_ID: message.message_thread_id,
}
if filters.COMMAND.filter(message):
# This is a command message - set event type to command and split data into command and args
diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json
index 1a02543d4ab..714e7b74db0 100644
--- a/homeassistant/components/telegram_bot/strings.json
+++ b/homeassistant/components/telegram_bot/strings.json
@@ -14,7 +14,7 @@
},
"target": {
"name": "Target",
- "description": "An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default."
+ "description": "An array of pre-authorized chat IDs to send the notification to. If not present, first allowed chat ID is the default."
},
"parse_mode": {
"name": "Parse mode",
@@ -30,7 +30,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send message. Will help with timeout errors (poor internet connection, etc)s."
+ "description": "Timeout for sending the message in seconds. Will help with timeout errors (poor Internet connection, etc)."
},
"keyboard": {
"name": "Keyboard",
@@ -45,11 +45,11 @@
"description": "Tag for sent message."
},
"reply_to_message_id": {
- "name": "Reply to message id",
+ "name": "Reply to message ID",
"description": "Mark the message as a reply to a previous message."
},
"message_thread_id": {
- "name": "Message thread id",
+ "name": "Message thread ID",
"description": "Unique identifier for the target message thread (topic) of the forum; for forum supergroups only."
}
}
@@ -84,7 +84,7 @@
},
"target": {
"name": "Target",
- "description": "An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default."
+ "description": "An array of pre-authorized chat IDs to send the document to. If not present, first allowed chat ID is the default."
},
"parse_mode": {
"name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]",
@@ -100,7 +100,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send photo."
+ "description": "Timeout for sending the photo in seconds."
},
"keyboard": {
"name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]",
@@ -166,7 +166,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send sticker."
+ "description": "Timeout for sending the sticker in seconds."
},
"keyboard": {
"name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]",
@@ -306,7 +306,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send video."
+ "description": "Timeout for sending the video in seconds."
},
"keyboard": {
"name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]",
@@ -372,7 +372,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send voice."
+ "description": "Timeout for sending the voice in seconds."
},
"keyboard": {
"name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]",
@@ -442,7 +442,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send document."
+ "description": "Timeout for sending the document in seconds."
},
"keyboard": {
"name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]",
@@ -480,7 +480,7 @@
},
"target": {
"name": "Target",
- "description": "An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default."
+ "description": "An array of pre-authorized chat IDs to send the location to. If not present, first allowed chat ID is the default."
},
"disable_notification": {
"name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]",
@@ -546,7 +546,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for send poll."
+ "description": "Timeout for sending the poll in seconds."
},
"message_tag": {
"name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]",
@@ -568,11 +568,11 @@
"fields": {
"message_id": {
"name": "Message ID",
- "description": "Id of the message to edit."
+ "description": "ID of the message to edit."
},
"chat_id": {
"name": "Chat ID",
- "description": "The chat_id where to edit the message."
+ "description": "ID of the chat where to edit the message."
},
"message": {
"name": "Message",
@@ -606,7 +606,7 @@
},
"chat_id": {
"name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]",
- "description": "The chat_id where to edit the caption."
+ "description": "ID of the chat where to edit the caption."
},
"caption": {
"name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]",
@@ -620,7 +620,7 @@
},
"edit_replymarkup": {
"name": "Edit reply markup",
- "description": "Edit the inline keyboard of a previously sent message.",
+ "description": "Edits the inline keyboard of a previously sent message.",
"fields": {
"message_id": {
"name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]",
@@ -628,7 +628,7 @@
},
"chat_id": {
"name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]",
- "description": "The chat_id where to edit the reply_markup."
+ "description": "ID of the chat where to edit the reply markup."
},
"inline_keyboard": {
"name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]",
@@ -646,7 +646,7 @@
},
"callback_query_id": {
"name": "Callback query ID",
- "description": "Unique id of the callback response."
+ "description": "Unique ID of the callback response."
},
"show_alert": {
"name": "Show alert",
@@ -654,7 +654,7 @@
},
"timeout": {
"name": "Read timeout",
- "description": "Read timeout for sending the answer."
+ "description": "Timeout for sending the answer in seconds."
}
}
},
@@ -664,11 +664,11 @@
"fields": {
"message_id": {
"name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]",
- "description": "Id of the message to delete."
+ "description": "ID of the message to delete."
},
"chat_id": {
"name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]",
- "description": "The chat_id where to delete the message."
+ "description": "ID of the chat where to delete the message."
}
}
}
diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py
index 3eb3c71a0bb..9bd360f5e41 100644
--- a/homeassistant/components/telegram_bot/webhooks.py
+++ b/homeassistant/components/telegram_bot/webhooks.py
@@ -109,13 +109,12 @@ class PushBot(BaseTelegramBotEntity):
else:
_LOGGER.debug("telegram webhook status: %s", current_status)
- if current_status and current_status["url"] != self.webhook_url:
- result = await self._try_to_set_webhook()
- if result:
- _LOGGER.debug("Set new telegram webhook %s", self.webhook_url)
- else:
- _LOGGER.error("Set telegram webhook failed %s", self.webhook_url)
- return False
+ result = await self._try_to_set_webhook()
+ if result:
+ _LOGGER.debug("Set new telegram webhook %s", self.webhook_url)
+ else:
+ _LOGGER.error("Set telegram webhook failed %s", self.webhook_url)
+ return False
return True
diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json
index e363aced667..b0750a7785d 100644
--- a/homeassistant/components/tellduslive/strings.json
+++ b/homeassistant/components/tellduslive/strings.json
@@ -11,15 +11,15 @@
},
"step": {
"auth": {
- "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})",
- "title": "Authenticate against TelldusLive"
+ "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})",
+ "title": "Authenticate with TelldusLive"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
- "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API."
+ "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for local API."
}
}
}
diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py
index 9d120b7aaa8..6ccc1f14b5f 100644
--- a/homeassistant/components/tellstick/__init__.py
+++ b/homeassistant/components/tellstick/__init__.py
@@ -9,8 +9,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py
index 1e27511bd84..c777aa6f01f 100644
--- a/homeassistant/components/tellstick/sensor.py
+++ b/homeassistant/components/tellstick/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py
index 82d8905a775..0fa1076c943 100644
--- a/homeassistant/components/telnet/switch.py
+++ b/homeassistant/components/telnet/switch.py
@@ -4,9 +4,9 @@ from __future__ import annotations
from datetime import timedelta
import logging
-import telnetlib # pylint: disable=deprecated-module
from typing import Any
+import telnetlib # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.switch import (
@@ -26,7 +26,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py
index 390a4a31bdb..15a73cf3de5 100644
--- a/homeassistant/components/template/__init__.py
+++ b/homeassistant/components/template/__init__.py
@@ -53,6 +53,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _reload_config(call: Event | ServiceCall) -> None:
"""Reload top-level + platforms."""
+ await async_get_blueprints(hass).async_reset_cache()
try:
unprocessed_conf = await conf_util.async_hass_config_yaml(hass)
except HomeAssistantError as err:
@@ -93,7 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
continue
if isinstance(entry.options[key], str):
raise ConfigEntryError(
- f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to "
+ f"The '{entry.options.get(CONF_NAME) or ''}' number template needs to "
f"be reconfigured, {key} must be a number, got '{entry.options[key]}'"
)
diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py
index aa1f99f0423..a67e2969f9a 100644
--- a/homeassistant/components/template/alarm_control_panel.py
+++ b/homeassistant/components/template/alarm_control_panel.py
@@ -28,8 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import selector
-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 import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index 922f1d88ffb..3c6e4899502 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -40,8 +40,7 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import selector, template
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index 2642ede9c3a..306b4405c6a 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index 7720ef7e1b3..6ed525fd45f 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index f194154a50c..0804f92e46d 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py
index f5b84b1ad7a..8f9edca5976 100644
--- a/homeassistant/components/template/template_entity.py
+++ b/homeassistant/components/template/template_entity.py
@@ -8,7 +8,7 @@ import itertools
import logging
from typing import Any, cast
-from propcache import under_cached_property
+from propcache.api import under_cached_property
import voluptuous as vol
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
@@ -33,7 +33,7 @@ from homeassistant.core import (
validate_state,
)
from homeassistant.exceptions import TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
TrackTemplate,
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index 19029cc708b..b977f4e659a 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py
index f4a3a7bfe07..15addd3513d 100644
--- a/homeassistant/components/tensorflow/image_processing.py
+++ b/homeassistant/components/tensorflow/image_processing.py
@@ -26,8 +26,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
)
from homeassistant.core import HomeAssistant, split_entity_id
-from homeassistant.helpers import template
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.pil import draw_box
diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json
index 16de386b15d..81705e326f7 100644
--- a/homeassistant/components/tensorflow/manifest.json
+++ b/homeassistant/components/tensorflow/manifest.json
@@ -10,7 +10,7 @@
"tensorflow==2.5.0",
"tf-models-official==2.5.0",
"pycocotools==2.0.6",
- "numpy==2.2.0",
- "Pillow==11.0.0"
+ "numpy==2.2.2",
+ "Pillow==11.1.0"
]
}
diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py
index ff50a99748e..634e8f845f9 100644
--- a/homeassistant/components/tesla_fleet/__init__.py
+++ b/homeassistant/components/tesla_fleet/__init__.py
@@ -25,17 +25,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN, LOGGER, MODELS
from .coordinator import (
+ TeslaFleetEnergySiteHistoryCoordinator,
TeslaFleetEnergySiteInfoCoordinator,
TeslaFleetEnergySiteLiveCoordinator,
TeslaFleetVehicleDataCoordinator,
@@ -176,9 +176,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
api = EnergySpecific(tesla.energy, site_id)
live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api)
+ history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(hass, api)
info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product)
await live_coordinator.async_config_entry_first_refresh()
+ await history_coordinator.async_config_entry_first_refresh()
await info_coordinator.async_config_entry_first_refresh()
# Create energy site model
@@ -211,6 +213,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
TeslaFleetEnergyData(
api=api,
live_coordinator=live_coordinator,
+ history_coordinator=history_coordinator,
info_coordinator=info_coordinator,
id=site_id,
device=device,
diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py
index 9b3baf49bfb..5d2dc84c49e 100644
--- a/homeassistant/components/tesla_fleet/const.py
+++ b/homeassistant/components/tesla_fleet/const.py
@@ -37,6 +37,30 @@ MODELS = {
"T": "Tesla Semi",
}
+ENERGY_HISTORY_FIELDS = [
+ "solar_energy_exported",
+ "generator_energy_exported",
+ "grid_energy_imported",
+ "grid_services_energy_imported",
+ "grid_services_energy_exported",
+ "grid_energy_exported_from_solar",
+ "grid_energy_exported_from_generator",
+ "grid_energy_exported_from_battery",
+ "battery_energy_exported",
+ "battery_energy_imported_from_grid",
+ "battery_energy_imported_from_solar",
+ "battery_energy_imported_from_generator",
+ "consumer_energy_imported_from_grid",
+ "consumer_energy_imported_from_solar",
+ "consumer_energy_imported_from_battery",
+ "consumer_energy_imported_from_generator",
+ "total_home_usage",
+ "total_battery_charge",
+ "total_battery_discharge",
+ "total_solar_generation",
+ "total_grid_energy_exported",
+]
+
class TeslaFleetState(StrEnum):
"""Teslemetry Vehicle States."""
diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py
index 42b93352a6f..4d99319d49f 100644
--- a/homeassistant/components/tesla_fleet/coordinator.py
+++ b/homeassistant/components/tesla_fleet/coordinator.py
@@ -1,10 +1,12 @@
"""Tesla Fleet Data Coordinator."""
from datetime import datetime, timedelta
+from random import randint
+from time import time
from typing import Any
from tesla_fleet_api import EnergySpecific, VehicleSpecific
-from tesla_fleet_api.const import VehicleDataEndpoint
+from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
InvalidToken,
LoginRequired,
@@ -19,14 +21,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import LOGGER, TeslaFleetState
+from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState
-VEHICLE_INTERVAL_SECONDS = 90
+VEHICLE_INTERVAL_SECONDS = 300
VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS)
VEHICLE_WAIT = timedelta(minutes=15)
ENERGY_INTERVAL_SECONDS = 60
ENERGY_INTERVAL = timedelta(seconds=ENERGY_INTERVAL_SECONDS)
+ENERGY_HISTORY_INTERVAL = timedelta(minutes=5)
ENDPOINTS = [
VehicleDataEndpoint.CHARGE_STATE,
@@ -73,7 +76,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.data = flatten(product)
self.updated_once = False
self.last_active = datetime.now()
- self.rate = RateCalculator(200, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5)
+ self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5)
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using TeslaFleet API."""
@@ -182,6 +185,61 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
return data
+class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Class to manage fetching energy site history import and export from the Tesla Fleet API."""
+
+ def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None:
+ """Initialize Tesla Fleet Energy Site History coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=f"Tesla Fleet Energy History {api.energy_site_id}",
+ update_interval=timedelta(seconds=300),
+ )
+ self.api = api
+ self.data = {}
+ self.updated_once = False
+
+ async def async_config_entry_first_refresh(self) -> None:
+ """Set up the data coordinator."""
+ await super().async_config_entry_first_refresh()
+
+ # Calculate seconds until next 5 minute period plus a random delay
+ delta = randint(310, 330) - (int(time()) % 300)
+ self.logger.debug("Scheduling next %s refresh in %s seconds", self.name, delta)
+ self.update_interval = timedelta(seconds=delta)
+ self._schedule_refresh()
+ self.update_interval = ENERGY_HISTORY_INTERVAL
+
+ async def _async_update_data(self) -> dict[str, Any]:
+ """Update energy site history data using Tesla Fleet API."""
+
+ try:
+ data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
+ except RateLimited as e:
+ LOGGER.warning(
+ "%s rate limited, will retry in %s seconds",
+ self.name,
+ e.data.get("after"),
+ )
+ if "after" in e.data:
+ self.update_interval = timedelta(seconds=int(e.data["after"]))
+ return self.data
+ except (InvalidToken, OAuthExpired, LoginRequired) as e:
+ raise ConfigEntryAuthFailed from e
+ except TeslaFleetError as e:
+ raise UpdateFailed(e.message) from e
+ self.updated_once = True
+
+ # Add all time periods together
+ output = {key: 0 for key in ENERGY_HISTORY_FIELDS}
+ for period in data.get("time_series", []):
+ for key in ENERGY_HISTORY_FIELDS:
+ output[key] += period.get(key, 0)
+
+ return output
+
+
class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site info from the TeslaFleet API."""
diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py
index 0ee41b5e322..0260acf368e 100644
--- a/homeassistant/components/tesla_fleet/entity.py
+++ b/homeassistant/components/tesla_fleet/entity.py
@@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import (
+ TeslaFleetEnergySiteHistoryCoordinator,
TeslaFleetEnergySiteInfoCoordinator,
TeslaFleetEnergySiteLiveCoordinator,
TeslaFleetVehicleDataCoordinator,
@@ -24,6 +25,7 @@ class TeslaFleetEntity(
CoordinatorEntity[
TeslaFleetVehicleDataCoordinator
| TeslaFleetEnergySiteLiveCoordinator
+ | TeslaFleetEnergySiteHistoryCoordinator
| TeslaFleetEnergySiteInfoCoordinator
]
):
@@ -37,6 +39,7 @@ class TeslaFleetEntity(
self,
coordinator: TeslaFleetVehicleDataCoordinator
| TeslaFleetEnergySiteLiveCoordinator
+ | TeslaFleetEnergySiteHistoryCoordinator
| TeslaFleetEnergySiteInfoCoordinator,
api: VehicleSpecific | EnergySpecific,
key: str,
@@ -139,6 +142,21 @@ class TeslaFleetEnergyLiveEntity(TeslaFleetEntity):
super().__init__(data.live_coordinator, data.api, key)
+class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity):
+ """Parent class for TeslaFleet Energy Site History entities."""
+
+ def __init__(
+ self,
+ data: TeslaFleetEnergyData,
+ key: str,
+ ) -> None:
+ """Initialize common aspects of a Tesla Fleet Energy Site History entity."""
+ self._attr_unique_id = f"{data.id}-{key}"
+ self._attr_device_info = data.device
+
+ super().__init__(data.history_coordinator, data.api, key)
+
+
class TeslaFleetEnergyInfoEntity(TeslaFleetEntity):
"""Parent class for TeslaFleet Energy Site Info entities."""
diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json
index 449dda93c62..f907107fd40 100644
--- a/homeassistant/components/tesla_fleet/icons.json
+++ b/homeassistant/components/tesla_fleet/icons.json
@@ -232,10 +232,73 @@
"island_status_unknown": "mdi:help-circle",
"off_grid_intentional": "mdi:account-cancel"
}
+ },
+ "total_home_usage": {
+ "default": "mdi:home-lightning-bolt"
+ },
+ "total_battery_charge": {
+ "default": "mdi:battery-arrow-up"
+ },
+ "total_battery_discharge": {
+ "default": "mdi:battery-arrow-down"
+ },
+ "total_solar_production": {
+ "default": "mdi:solar-power-variant"
+ },
+ "grid_energy_imported": {
+ "default": "mdi:transmission-tower-import"
+ },
+ "total_grid_energy_exported": {
+ "default": "mdi:transmission-tower-export"
+ },
+ "solar_energy_exported": {
+ "default": "mdi:solar-power-variant"
+ },
+ "generator_energy_exported": {
+ "default": "mdi:generator-stationary"
+ },
+ "grid_services_energy_imported": {
+ "default": "mdi:transmission-tower-import"
+ },
+ "grid_services_energy_exported": {
+ "default": "mdi:transmission-tower-export"
+ },
+ "grid_energy_exported_from_solar": {
+ "default": "mdi:solar-power"
+ },
+ "grid_energy_exported_from_generator": {
+ "default": "mdi:generator-stationary"
+ },
+ "grid_energy_exported_from_battery": {
+ "default": "mdi:battery-arrow-down"
+ },
+ "battery_energy_exported": {
+ "default": "mdi:battery-arrow-down"
+ },
+ "battery_energy_imported_from_grid": {
+ "default": "mdi:transmission-tower-import"
+ },
+ "battery_energy_imported_from_solar": {
+ "default": "mdi:solar-power"
+ },
+ "battery_energy_imported_from_generator": {
+ "default": "mdi:generator-stationary"
+ },
+ "consumer_energy_imported_from_grid": {
+ "default": "mdi:transmission-tower-import"
+ },
+ "consumer_energy_imported_from_solar": {
+ "default": "mdi:solar-power"
+ },
+ "consumer_energy_imported_from_battery": {
+ "default": "mdi:home-battery"
+ },
+ "consumer_energy_imported_from_generator": {
+ "default": "mdi:generator-stationary"
}
},
"switch": {
- "charge_state_user_charge_enable_request": {
+ "charge_state_charging_state": {
"default": "mdi:ev-station"
},
"climate_state_auto_seat_climate_left": {
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 95062a8f856..330745316d7 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==0.8.5"]
+ "requirements": ["tesla-fleet-api==0.9.8"]
}
diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py
index ae945dd96bf..469ebdca914 100644
--- a/homeassistant/components/tesla_fleet/models.py
+++ b/homeassistant/components/tesla_fleet/models.py
@@ -11,6 +11,7 @@ from tesla_fleet_api.const import Scope
from homeassistant.helpers.device_registry import DeviceInfo
from .coordinator import (
+ TeslaFleetEnergySiteHistoryCoordinator,
TeslaFleetEnergySiteInfoCoordinator,
TeslaFleetEnergySiteLiveCoordinator,
TeslaFleetVehicleDataCoordinator,
@@ -44,6 +45,7 @@ class TeslaFleetEnergyData:
api: EnergySpecific
live_coordinator: TeslaFleetEnergySiteLiveCoordinator
+ history_coordinator: TeslaFleetEnergySiteHistoryCoordinator
info_coordinator: TeslaFleetEnergySiteInfoCoordinator
id: int
device: DeviceInfo
diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py
index b4e7b51faba..c1d38bf85c5 100644
--- a/homeassistant/components/tesla_fleet/sensor.py
+++ b/homeassistant/components/tesla_fleet/sensor.py
@@ -35,8 +35,9 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance
from . import TeslaFleetConfigEntry
-from .const import TeslaFleetState
+from .const import ENERGY_HISTORY_FIELDS, TeslaFleetState
from .entity import (
+ TeslaFleetEnergyHistoryEntity,
TeslaFleetEnergyInfoEntity,
TeslaFleetEnergyLiveEntity,
TeslaFleetVehicleEntity,
@@ -302,8 +303,8 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslaFleetTimeEntityDescription, ...] = (
),
)
-ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
- SensorEntityDescription(
+ENERGY_LIVE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = (
+ TeslaFleetSensorEntityDescription(
key="solar_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -311,7 +312,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="energy_left",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -320,7 +321,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="total_pack_energy",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -330,14 +331,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="percentage_charged",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
suggested_display_precision=2,
+ value_fn=lambda value: value or 0,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="battery_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -345,7 +347,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="load_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -353,7 +355,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="grid_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -361,7 +363,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="grid_services_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -369,7 +371,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="generator_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -378,7 +380,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
),
- SensorEntityDescription(
+ TeslaFleetSensorEntityDescription(
key="island_status",
options=[
"island_status_unknown",
@@ -415,6 +417,21 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
),
)
+ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple(
+ SensorEntityDescription(
+ key=key,
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=(
+ key.startswith("total") or key == "grid_energy_imported"
+ ),
+ )
+ for key in ENERGY_HISTORY_FIELDS
+)
+
ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="vpp_backup_reserve_percent",
@@ -450,6 +467,13 @@ async def async_setup_entry(
for description in ENERGY_LIVE_DESCRIPTIONS
if description.key in energysite.live_coordinator.data
),
+ ( # Add energy site history
+ TeslaFleetEnergyHistorySensorEntity(energysite, description)
+ for energysite in entry.runtime_data.energysites
+ for description in ENERGY_HISTORY_DESCRIPTIONS
+ if energysite.info_coordinator.data.get("components_battery")
+ or energysite.info_coordinator.data.get("components_solar")
+ ),
( # Add wall connectors
TeslaFleetWallConnectorSensorEntity(energysite, wc["din"], description)
for energysite in entry.runtime_data.energysites
@@ -527,6 +551,25 @@ class TeslaFleetVehicleTimeSensorEntity(TeslaFleetVehicleEntity, SensorEntity):
class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity):
"""Base class for Tesla Fleet energy site metric sensors."""
+ entity_description: TeslaFleetSensorEntityDescription
+
+ def __init__(
+ self,
+ data: TeslaFleetEnergyData,
+ description: TeslaFleetSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ self.entity_description = description
+ super().__init__(data, description.key)
+
+ def _async_update_attrs(self) -> None:
+ """Update the attributes of the sensor."""
+ self._attr_native_value = self.entity_description.value_fn(self._value)
+
+
+class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity):
+ """Base class for Tesla Fleet energy site metric sensors."""
+
entity_description: SensorEntityDescription
def __init__(
@@ -540,7 +583,6 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity)
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
- self._attr_available = not self.is_none
self._attr_native_value = self._value
diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json
index fe5cd06c1ef..540ea2b7135 100644
--- a/homeassistant/components/tesla_fleet/strings.json
+++ b/homeassistant/components/tesla_fleet/strings.json
@@ -424,6 +424,9 @@
"off_grid_intentional": "Disconnected intentionally"
}
},
+ "storm_mode_active": {
+ "name": "Storm Watch active"
+ },
"vehicle_state_tpms_pressure_fl": {
"name": "Tire pressure front left"
},
@@ -453,10 +456,73 @@
},
"wall_connector_state": {
"name": "State code"
+ },
+ "solar_energy_exported": {
+ "name": "Solar exported"
+ },
+ "generator_energy_exported": {
+ "name": "Generator exported"
+ },
+ "grid_energy_imported": {
+ "name": "Grid imported"
+ },
+ "grid_services_energy_imported": {
+ "name": "Grid services imported"
+ },
+ "grid_services_energy_exported": {
+ "name": "Grid services exported"
+ },
+ "grid_energy_exported_from_solar": {
+ "name": "Grid exported from solar"
+ },
+ "grid_energy_exported_from_generator": {
+ "name": "Grid exported from generator"
+ },
+ "grid_energy_exported_from_battery": {
+ "name": "Grid exported from battery"
+ },
+ "battery_energy_exported": {
+ "name": "Battery exported"
+ },
+ "battery_energy_imported_from_grid": {
+ "name": "Battery imported from grid"
+ },
+ "battery_energy_imported_from_solar": {
+ "name": "Battery imported from solar"
+ },
+ "battery_energy_imported_from_generator": {
+ "name": "Battery imported from generator"
+ },
+ "consumer_energy_imported_from_grid": {
+ "name": "Consumer imported from grid"
+ },
+ "consumer_energy_imported_from_solar": {
+ "name": "Consumer imported from solar"
+ },
+ "consumer_energy_imported_from_battery": {
+ "name": "Consumer imported from battery"
+ },
+ "consumer_energy_imported_from_generator": {
+ "name": "Consumer imported from generator"
+ },
+ "total_home_usage": {
+ "name": "Home usage"
+ },
+ "total_battery_charge": {
+ "name": "Battery charged"
+ },
+ "total_battery_discharge": {
+ "name": "Battery discharged"
+ },
+ "total_solar_generation": {
+ "name": "Solar generated"
+ },
+ "total_grid_energy_exported": {
+ "name": "Grid exported"
}
},
"switch": {
- "charge_state_user_charge_enable_request": {
+ "charge_state_charging_state": {
"name": "Charge"
},
"climate_state_auto_seat_climate_left": {
diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py
index d602cff78c0..054ea84cbe1 100644
--- a/homeassistant/components/tesla_fleet/switch.py
+++ b/homeassistant/components/tesla_fleet/switch.py
@@ -16,6 +16,7 @@ from homeassistant.components.switch import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
from . import TeslaFleetConfigEntry
from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity
@@ -32,6 +33,8 @@ class TeslaFleetSwitchEntityDescription(SwitchEntityDescription):
on_func: Callable
off_func: Callable
scopes: list[Scope]
+ value_func: Callable[[StateType], bool] = bool
+ unique_id: str | None = None
VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = (
@@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = (
),
scopes=[Scope.VEHICLE_CMDS],
),
-)
-
-VEHICLE_CHARGE_DESCRIPTION = TeslaFleetSwitchEntityDescription(
- key="charge_state_user_charge_enable_request",
- on_func=lambda api: api.charge_start(),
- off_func=lambda api: api.charge_stop(),
- scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS],
+ TeslaFleetSwitchEntityDescription(
+ key="charge_state_charging_state",
+ unique_id="charge_state_user_charge_enable_request",
+ on_func=lambda api: api.charge_start(),
+ off_func=lambda api: api.charge_stop(),
+ value_func=lambda state: state in {"Starting", "Charging"},
+ scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS],
+ ),
)
@@ -103,12 +107,6 @@ async def async_setup_entry(
for vehicle in entry.runtime_data.vehicles
for description in VEHICLE_DESCRIPTIONS
),
- (
- TeslaFleetChargeSwitchEntity(
- vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes
- )
- for vehicle in entry.runtime_data.vehicles
- ),
(
TeslaFleetChargeFromGridSwitchEntity(
energysite,
@@ -144,16 +142,18 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt
scopes: list[Scope],
) -> None:
"""Initialize the Switch."""
- super().__init__(data, description.key)
self.entity_description = description
self.scoped = any(scope in scopes for scope in description.scopes)
+ super().__init__(data, description.key)
+ if description.unique_id:
+ self._attr_unique_id = f"{data.vin}-{description.unique_id}"
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
if self._value is None:
self._attr_is_on = None
else:
- self._attr_is_on = bool(self._value)
+ self._attr_is_on = self.entity_description.value_func(self._value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the Switch."""
@@ -172,17 +172,6 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt
self.async_write_ha_state()
-class TeslaFleetChargeSwitchEntity(TeslaFleetVehicleSwitchEntity):
- """Entity class for TeslaFleet charge switch."""
-
- def _async_update_attrs(self) -> None:
- """Update the attributes of the entity."""
- if self._value is None:
- self._attr_is_on = self.get("charge_state_charge_enable_request")
- else:
- self._attr_is_on = self._value
-
-
class TeslaFleetChargeFromGridSwitchEntity(
TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity
):
diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py
index 3296539f701..d100b1e5549 100644
--- a/homeassistant/components/tesla_wall_connector/config_flow.py
+++ b/homeassistant/components/tesla_wall_connector/config_flow.py
@@ -9,11 +9,11 @@ from tesla_wall_connector import WallConnector
from tesla_wall_connector.exceptions import WallConnectorError
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER
@@ -48,7 +48,7 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN):
self.ip_address: str | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery."""
self.ip_address = discovery_info.ip
diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py
index 5779283b955..6e60b34825f 100644
--- a/homeassistant/components/teslemetry/__init__.py
+++ b/homeassistant/components/teslemetry/__init__.py
@@ -7,6 +7,7 @@ from typing import Final
from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
+ Forbidden,
InvalidToken,
SubscriptionRequired,
TeslaFleetError,
@@ -17,9 +18,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import ConfigType
@@ -126,13 +126,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
create_handle_vehicle_stream(vin, coordinator),
{"vin": vin},
)
+ firmware = vehicle_metadata[vin].get("firmware", "Unknown")
+ stream_vehicle = stream.get_vehicle(vin)
vehicles.append(
TeslemetryVehicleData(
api=api,
+ config_entry=entry,
coordinator=coordinator,
stream=stream,
+ stream_vehicle=stream_vehicle,
vin=vin,
+ firmware=firmware,
device=device,
remove_listener=remove_listener,
)
@@ -160,10 +165,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
serial_number=str(site_id),
)
+ # Check live status endpoint works before creating its coordinator
+ try:
+ live_status = (await api.live_status())["response"]
+ except (InvalidToken, Forbidden, SubscriptionRequired) as e:
+ raise ConfigEntryAuthFailed from e
+ except TeslaFleetError as e:
+ raise ConfigEntryNotReady(e.message) from e
+
energysites.append(
TeslemetryEnergyData(
api=api,
- live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api),
+ live_coordinator=(
+ TeslemetryEnergySiteLiveCoordinator(hass, api, live_status)
+ if isinstance(live_status, dict)
+ else None
+ ),
info_coordinator=TeslemetryEnergySiteInfoCoordinator(
hass, api, product
),
@@ -179,14 +196,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
# Run all first refreshes
await asyncio.gather(
+ *(async_setup_stream(hass, entry, vehicle) for vehicle in vehicles),
*(
vehicle.coordinator.async_config_entry_first_refresh()
for vehicle in vehicles
),
- *(
- energysite.live_coordinator.async_config_entry_first_refresh()
- for energysite in energysites
- ),
*(
energysite.info_coordinator.async_config_entry_first_refresh()
for energysite in energysites
@@ -265,3 +279,16 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None
coordinator.async_set_updated_data(coordinator.data)
return handle_vehicle_stream
+
+
+async def async_setup_stream(
+ hass: HomeAssistant, entry: ConfigEntry, vehicle: TeslemetryVehicleData
+):
+ """Set up the stream for a vehicle."""
+
+ await vehicle.stream_vehicle.get_config()
+ entry.async_create_background_task(
+ hass,
+ vehicle.stream_vehicle.prefer_typed(True),
+ f"Prefer typed for {vehicle.vin}",
+ )
diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py
index 29ebfea4db1..0b6823f8b61 100644
--- a/homeassistant/components/teslemetry/binary_sensor.py
+++ b/homeassistant/components/teslemetry/binary_sensor.py
@@ -4,17 +4,20 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from itertools import chain
from typing import cast
+from teslemetry_stream import Signal
+from teslemetry_stream.const import WindowState
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.const import EntityCategory
+from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType
from . import TeslemetryConfigEntry
@@ -23,6 +26,7 @@ from .entity import (
TeslemetryEnergyInfoEntity,
TeslemetryEnergyLiveEntity,
TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
)
from .models import TeslemetryEnergyData, TeslemetryVehicleData
@@ -33,133 +37,327 @@ PARALLEL_UPDATES = 0
class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Teslemetry binary sensor entity."""
- is_on: Callable[[StateType], bool] = bool
+ polling_value_fn: Callable[[StateType], bool | None] = bool
+ polling: bool = False
+ streaming_key: Signal | None = None
+ streaming_firmware: str = "2024.26"
+ streaming_value_fn: Callable[[StateType], bool | None] = (
+ lambda x: x is True or x == "true"
+ )
VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="state",
+ polling=True,
+ polling_value_fn=lambda x: x == TeslemetryState.ONLINE,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
- is_on=lambda x: x == TeslemetryState.ONLINE,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_battery_heater_on",
+ polling=True,
+ streaming_key=Signal.BATTERY_HEATER_ON,
device_class=BinarySensorDeviceClass.HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_charger_phases",
- is_on=lambda x: cast(int, x) > 1,
+ polling=True,
+ streaming_key=Signal.CHARGER_PHASES,
+ polling_value_fn=lambda x: cast(int, x) > 1,
+ streaming_value_fn=lambda x: cast(int, x) > 1,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_preconditioning_enabled",
+ polling=True,
+ streaming_key=Signal.PRECONDITIONING_ENABLED,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="climate_state_is_preconditioning",
+ polling=True,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_scheduled_charging_pending",
+ polling=True,
+ streaming_key=Signal.SCHEDULED_CHARGING_PENDING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_trip_charging",
+ polling=True,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_conn_charge_cable",
- is_on=lambda x: x != "",
+ polling=True,
+ polling_value_fn=lambda x: x != "",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
TeslemetryBinarySensorEntityDescription(
key="climate_state_cabin_overheat_protection_actively_cooling",
+ polling=True,
device_class=BinarySensorDeviceClass.HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_dashcam_state",
+ polling=True,
device_class=BinarySensorDeviceClass.RUNNING,
- is_on=lambda x: x == "Recording",
+ polling_value_fn=lambda x: x == "Recording",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_is_user_present",
+ polling=True,
device_class=BinarySensorDeviceClass.PRESENCE,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_tpms_soft_warning_fl",
+ polling=True,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_tpms_soft_warning_fr",
+ polling=True,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_tpms_soft_warning_rl",
+ polling=True,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_tpms_soft_warning_rr",
+ polling=True,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_fd_window",
+ polling=True,
+ streaming_key=Signal.FD_WINDOW,
+ streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_fp_window",
+ polling=True,
+ streaming_key=Signal.FP_WINDOW,
+ streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_rd_window",
+ polling=True,
+ streaming_key=Signal.RD_WINDOW,
+ streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_rp_window",
+ polling=True,
+ streaming_key=Signal.RP_WINDOW,
+ streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_df",
+ polling=True,
device_class=BinarySensorDeviceClass.DOOR,
+ streaming_key=Signal.DOOR_STATE,
+ streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_dr",
+ polling=True,
device_class=BinarySensorDeviceClass.DOOR,
+ streaming_key=Signal.DOOR_STATE,
+ streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_pf",
+ polling=True,
device_class=BinarySensorDeviceClass.DOOR,
+ streaming_key=Signal.DOOR_STATE,
+ streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_pr",
+ polling=True,
device_class=BinarySensorDeviceClass.DOOR,
+ streaming_key=Signal.DOOR_STATE,
+ streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"),
entity_category=EntityCategory.DIAGNOSTIC,
),
+ TeslemetryBinarySensorEntityDescription(
+ key="automatic_blind_spot_camera",
+ streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="automatic_emergency_braking_off",
+ streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="blind_spot_collision_warning_chime",
+ streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="bms_full_charge_complete",
+ streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="brake_pedal",
+ streaming_key=Signal.BRAKE_PEDAL,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="charge_port_cold_weather_mode",
+ streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="service_mode",
+ streaming_key=Signal.SERVICE_MODE,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="pin_to_drive_enabled",
+ streaming_key=Signal.PIN_TO_DRIVE_ENABLED,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="drive_rail",
+ streaming_key=Signal.DRIVE_RAIL,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="driver_seat_belt",
+ streaming_key=Signal.DRIVER_SEAT_BELT,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="driver_seat_occupied",
+ streaming_key=Signal.DRIVER_SEAT_OCCUPIED,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="passenger_seat_belt",
+ streaming_key=Signal.PASSENGER_SEAT_BELT,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="fast_charger_present",
+ streaming_key=Signal.FAST_CHARGER_PRESENT,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="gps_state",
+ streaming_key=Signal.GPS_STATE,
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="guest_mode_enabled",
+ streaming_key=Signal.GUEST_MODE_ENABLED,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="dc_dc_enable",
+ streaming_key=Signal.DC_DC_ENABLE,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="emergency_lane_departure_avoidance",
+ streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="supercharger_session_trip_planner",
+ streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER,
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="wiper_heat_enabled",
+ streaming_key=Signal.WIPER_HEAT_ENABLED,
+ streaming_firmware="2024.44.25",
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="rear_display_hvac_enabled",
+ streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED,
+ streaming_firmware="2024.44.25",
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="offroad_lightbar_present",
+ streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT,
+ streaming_firmware="2024.44.25",
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="homelink_nearby",
+ streaming_key=Signal.HOMELINK_NEARBY,
+ streaming_firmware="2024.44.25",
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="europe_vehicle",
+ streaming_key=Signal.EUROPE_VEHICLE,
+ streaming_firmware="2024.44.25",
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="right_hand_drive",
+ streaming_key=Signal.RIGHT_HAND_DRIVE,
+ streaming_firmware="2024.44.25",
+ entity_registry_enabled_default=False,
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="located_at_home",
+ streaming_key=Signal.LOCATED_AT_HOME,
+ streaming_firmware="2024.44.32",
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="located_at_work",
+ streaming_key=Signal.LOCATED_AT_WORK,
+ streaming_firmware="2024.44.32",
+ ),
+ TeslemetryBinarySensorEntityDescription(
+ key="located_at_favorite",
+ streaming_key=Signal.LOCATED_AT_FAVORITE,
+ streaming_firmware="2024.44.32",
+ entity_registry_enabled_default=False,
+ ),
)
ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
@@ -183,30 +381,42 @@ async def async_setup_entry(
) -> None:
"""Set up the Teslemetry binary sensor platform from a config entry."""
- async_add_entities(
- chain(
- ( # Vehicles
- TeslemetryVehicleBinarySensorEntity(vehicle, description)
- for vehicle in entry.runtime_data.vehicles
- for description in VEHICLE_DESCRIPTIONS
- ),
- ( # Energy Site Live
- TeslemetryEnergyLiveBinarySensorEntity(energysite, description)
- for energysite in entry.runtime_data.energysites
- for description in ENERGY_LIVE_DESCRIPTIONS
- if energysite.info_coordinator.data.get("components_battery")
- ),
- ( # Energy Site Info
- TeslemetryEnergyInfoBinarySensorEntity(energysite, description)
- for energysite in entry.runtime_data.energysites
- for description in ENERGY_INFO_DESCRIPTIONS
- if energysite.info_coordinator.data.get("components_battery")
- ),
- )
+ entities: list[BinarySensorEntity] = []
+ for vehicle in entry.runtime_data.vehicles:
+ for description in VEHICLE_DESCRIPTIONS:
+ if (
+ not vehicle.api.pre2021
+ and description.streaming_key
+ and vehicle.firmware >= description.streaming_firmware
+ ):
+ entities.append(
+ TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description)
+ )
+ elif description.polling:
+ entities.append(
+ TeslemetryVehiclePollingBinarySensorEntity(vehicle, description)
+ )
+
+ entities.extend(
+ TeslemetryEnergyLiveBinarySensorEntity(energysite, description)
+ for energysite in entry.runtime_data.energysites
+ if energysite.live_coordinator
+ for description in ENERGY_LIVE_DESCRIPTIONS
+ if description.key in energysite.live_coordinator.data
+ )
+ entities.extend(
+ TeslemetryEnergyInfoBinarySensorEntity(energysite, description)
+ for energysite in entry.runtime_data.energysites
+ for description in ENERGY_INFO_DESCRIPTIONS
+ if description.key in energysite.info_coordinator.data
)
+ async_add_entities(entities)
-class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorEntity):
+
+class TeslemetryVehiclePollingBinarySensorEntity(
+ TeslemetryVehicleEntity, BinarySensorEntity
+):
"""Base class for Teslemetry vehicle binary sensors."""
entity_description: TeslemetryBinarySensorEntityDescription
@@ -223,12 +433,40 @@ class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorE
def _async_update_attrs(self) -> None:
"""Update the attributes of the binary sensor."""
- if self._value is None:
- self._attr_available = False
- self._attr_is_on = None
- else:
- self._attr_available = True
- self._attr_is_on = self.entity_description.is_on(self._value)
+ self._attr_available = self._value is not None
+ if self._attr_available:
+ assert self._value is not None
+ self._attr_is_on = self.entity_description.polling_value_fn(self._value)
+
+
+class TeslemetryVehicleStreamingBinarySensorEntity(
+ TeslemetryVehicleStreamEntity, BinarySensorEntity, RestoreEntity
+):
+ """Base class for Teslemetry vehicle streaming sensors."""
+
+ entity_description: TeslemetryBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ description: TeslemetryBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ self.entity_description = description
+ assert description.streaming_key
+ super().__init__(data, description.key, description.streaming_key)
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ self._attr_is_on = state.state == STATE_ON
+
+ def _async_value_from_stream(self, value) -> None:
+ """Update the value of the entity."""
+ self._attr_available = value is not None
+ if self._attr_available:
+ self._attr_is_on = self.entity_description.streaming_value_fn(value)
class TeslemetryEnergyLiveBinarySensorEntity(
diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py
index a9bf3eddd6a..ceeda265795 100644
--- a/homeassistant/components/teslemetry/button.py
+++ b/homeassistant/components/teslemetry/button.py
@@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TeslemetryConfigEntry
from .entity import TeslemetryVehicleEntity
-from .helpers import handle_vehicle_command
+from .helpers import handle_command, handle_vehicle_command
from .models import TeslemetryVehicleData
PARALLEL_UPDATES = 0
@@ -24,28 +24,35 @@ PARALLEL_UPDATES = 0
class TeslemetryButtonEntityDescription(ButtonEntityDescription):
"""Describes a Teslemetry Button entity."""
- func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None
+ func: Callable[[TeslemetryButtonEntity], Awaitable[Any]]
DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = (
- TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup
TeslemetryButtonEntityDescription(
- key="flash_lights", func=lambda self: self.api.flash_lights()
+ key="wake", func=lambda self: handle_command(self.api.wake_up())
),
TeslemetryButtonEntityDescription(
- key="honk", func=lambda self: self.api.honk_horn()
+ key="flash_lights",
+ func=lambda self: handle_vehicle_command(self.api.flash_lights()),
),
TeslemetryButtonEntityDescription(
- key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive()
+ key="honk", func=lambda self: handle_vehicle_command(self.api.honk_horn())
),
TeslemetryButtonEntityDescription(
- key="boombox", func=lambda self: self.api.remote_boombox(0)
+ key="enable_keyless_driving",
+ func=lambda self: handle_vehicle_command(self.api.remote_start_drive()),
+ ),
+ TeslemetryButtonEntityDescription(
+ key="boombox",
+ func=lambda self: handle_vehicle_command(self.api.remote_boombox(0)),
),
TeslemetryButtonEntityDescription(
key="homelink",
- func=lambda self: self.api.trigger_homelink(
- lat=self.coordinator.data["drive_state_latitude"],
- lon=self.coordinator.data["drive_state_longitude"],
+ func=lambda self: handle_vehicle_command(
+ self.api.trigger_homelink(
+ lat=self.coordinator.data["drive_state_latitude"],
+ lon=self.coordinator.data["drive_state_longitude"],
+ )
),
),
)
@@ -85,6 +92,4 @@ class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
- await self.wake_up_if_asleep()
- if self.entity_description.func:
- await handle_vehicle_command(self.entity_description.func(self))
+ await self.entity_description.func(self)
diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py
index 303a3250edf..d39402c622c 100644
--- a/homeassistant/components/teslemetry/coordinator.py
+++ b/homeassistant/components/teslemetry/coordinator.py
@@ -69,7 +69,9 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site live status from the Teslemetry API."""
- def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None:
+ updated_once: bool
+
+ def __init__(self, hass: HomeAssistant, api: EnergySpecific, data: dict) -> None:
"""Initialize Teslemetry Energy Site Live coordinator."""
super().__init__(
hass,
@@ -79,6 +81,12 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
)
self.api = api
+ # Convert Wall Connectors from array to dict
+ data["wall_connectors"] = {
+ wc["din"]: wc for wc in (data.get("wall_connectors") or [])
+ }
+ self.data = data
+
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py
index d14ef385b9c..4cc15b6feb8 100644
--- a/homeassistant/components/teslemetry/cover.py
+++ b/homeassistant/components/teslemetry/cover.py
@@ -2,9 +2,12 @@
from __future__ import annotations
+from itertools import chain
from typing import Any
from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand
+from teslemetry_stream import Signal
+from teslemetry_stream.const import WindowState
from homeassistant.components.cover import (
CoverDeviceClass,
@@ -13,9 +16,14 @@ from homeassistant.components.cover import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
-from .entity import TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_vehicle_command
from .models import TeslemetryVehicleData
@@ -33,30 +41,95 @@ async def async_setup_entry(
"""Set up the Teslemetry cover platform from a config entry."""
async_add_entities(
- klass(vehicle, entry.runtime_data.scopes)
- for (klass) in (
- TeslemetryWindowEntity,
- TeslemetryChargePortEntity,
- TeslemetryFrontTrunkEntity,
- TeslemetryRearTrunkEntity,
- TeslemetrySunroofEntity,
+ chain(
+ (
+ TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes)
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.26"
+ else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes)
+ for vehicle in entry.runtime_data.vehicles
+ ),
+ (
+ TeslemetryPollingChargePortEntity(vehicle, entry.runtime_data.scopes)
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25"
+ else TeslemetryStreamingChargePortEntity(
+ vehicle, entry.runtime_data.scopes
+ )
+ for vehicle in entry.runtime_data.vehicles
+ ),
+ (
+ TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes)
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.26"
+ else TeslemetryStreamingFrontTrunkEntity(
+ vehicle, entry.runtime_data.scopes
+ )
+ for vehicle in entry.runtime_data.vehicles
+ ),
+ (
+ TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes)
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.26"
+ else TeslemetryStreamingRearTrunkEntity(
+ vehicle, entry.runtime_data.scopes
+ )
+ for vehicle in entry.runtime_data.vehicles
+ ),
+ (
+ TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes)
+ for vehicle in entry.runtime_data.vehicles
+ if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed")
+ ),
)
- for vehicle in entry.runtime_data.vehicles
)
-class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity):
- """Cover entity for the windows."""
+class CoverRestoreEntity(RestoreEntity, CoverEntity):
+ """Restore class for cover entities."""
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ if state.state == "open":
+ self._attr_is_closed = False
+ elif state.state == "closed":
+ self._attr_is_closed = True
+
+
+class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity):
+ """Base class for window cover entities."""
_attr_device_class = CoverDeviceClass.WINDOW
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Vent windows."""
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(
+ self.api.window_control(command=WindowCommand.VENT)
+ )
+ self._attr_is_closed = False
+ self.async_write_ha_state()
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close windows."""
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(
+ self.api.window_control(command=WindowCommand.CLOSE)
+ )
+ self._attr_is_closed = True
+ self.async_write_ha_state()
+
+
+class TeslemetryPollingWindowEntity(
+ TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity
+):
+ """Polling cover entity for windows."""
def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None:
"""Initialize the cover."""
super().__init__(data, "windows")
self.scoped = Scope.VEHICLE_CMDS in scopes
- self._attr_supported_features = (
- CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
- )
if not self.scoped:
self._attr_supported_features = CoverEntityFeature(0)
@@ -67,38 +140,108 @@ class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity):
rd = self.get("vehicle_state_rd_window")
rp = self.get("vehicle_state_rp_window")
- # Any open set to open
if OPEN in (fd, fp, rd, rp):
self._attr_is_closed = False
- # All closed set to closed
- elif CLOSED == fd == fp == rd == rp:
+ elif None in (fd, fp, rd, rp):
+ self._attr_is_closed = None
+ else:
self._attr_is_closed = True
- async def async_open_cover(self, **kwargs: Any) -> None:
- """Vent windows."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(
- self.api.window_control(command=WindowCommand.VENT)
+
+class TeslemetryStreamingWindowEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryWindowEntity, CoverRestoreEntity
+):
+ """Streaming cover entity for windows."""
+
+ fd: bool | None = None
+ fp: bool | None = None
+ rd: bool | None = None
+ rp: bool | None = None
+
+ def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None:
+ """Initialize the cover."""
+ super().__init__(
+ data,
+ "windows",
)
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = CoverEntityFeature(0)
+ self._attr_is_closed = None
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.stream.async_add_listener(
+ self._handle_stream_update,
+ {"vin": self.vin, "data": {self.streaming_key: None}},
+ )
+ )
+ for signal in (
+ Signal.FD_WINDOW,
+ Signal.FP_WINDOW,
+ Signal.RD_WINDOW,
+ Signal.RP_WINDOW,
+ ):
+ self.vehicle.config_entry.async_create_background_task(
+ self.hass,
+ self.add_field(signal),
+ f"Adding field {signal} to {self.vehicle.vin}",
+ )
+
+ def _handle_stream_update(self, data) -> None:
+ """Update the entity attributes."""
+
+ if value := data.get(Signal.FD_WINDOW):
+ self.fd = WindowState.get(value) == "closed"
+ if value := data.get(Signal.FP_WINDOW):
+ self.fp = WindowState.get(value) == "closed"
+ if value := data.get(Signal.RD_WINDOW):
+ self.rd = WindowState.get(value) == "closed"
+ if value := data.get(Signal.RP_WINDOW):
+ self.rp = WindowState.get(value) == "closed"
+
+ if False in (self.fd, self.fp, self.rd, self.rp):
+ self._attr_is_closed = False
+ elif None in (self.fd, self.fp, self.rd, self.rp):
+ self._attr_is_closed = None
+ else:
+ self._attr_is_closed = True
+
+ self.async_write_ha_state()
+
+
+class TeslemetryChargePortEntity(
+ TeslemetryRootEntity,
+ CoverEntity,
+):
+ """Base class for for charge port cover entities."""
+
+ _attr_device_class = CoverDeviceClass.DOOR
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open charge port."""
+ self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS)
+
+ await handle_vehicle_command(self.api.charge_port_door_open())
self._attr_is_closed = False
self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None:
- """Close windows."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(
- self.api.window_control(command=WindowCommand.CLOSE)
- )
+ """Close charge port."""
+ self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS)
+
+ await handle_vehicle_command(self.api.charge_port_door_close())
self._attr_is_closed = True
self.async_write_ha_state()
-class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity):
- """Cover entity for the charge port."""
-
- _attr_device_class = CoverDeviceClass.DOOR
+class TeslemetryPollingChargePortEntity(
+ TeslemetryVehicleEntity, TeslemetryChargePortEntity
+):
+ """Polling cover entity for the charge port."""
def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
"""Initialize the cover."""
@@ -117,75 +260,113 @@ class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity):
"""Update the entity attributes."""
self._attr_is_closed = not self._value
- async def async_open_cover(self, **kwargs: Any) -> None:
- """Open charge port."""
- self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.charge_port_door_open())
- self._attr_is_closed = False
- self.async_write_ha_state()
- async def async_close_cover(self, **kwargs: Any) -> None:
- """Close charge port."""
- self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.charge_port_door_close())
- self._attr_is_closed = True
- self.async_write_ha_state()
-
-
-class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity):
- """Cover entity for the front trunk."""
-
- _attr_device_class = CoverDeviceClass.DOOR
+class TeslemetryStreamingChargePortEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryChargePortEntity, CoverRestoreEntity
+):
+ """Streaming cover entity for the charge port."""
def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
- """Initialize the cover."""
- super().__init__(vehicle, "vehicle_state_ft")
-
- self.scoped = Scope.VEHICLE_CMDS in scopes
- self._attr_supported_features = CoverEntityFeature.OPEN
+ """Initialize the sensor."""
+ super().__init__(
+ vehicle,
+ "charge_state_charge_port_door_open",
+ )
+ self.scoped = any(
+ scope in scopes
+ for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS)
+ )
if not self.scoped:
self._attr_supported_features = CoverEntityFeature(0)
+ self._attr_is_closed = None
- def _async_update_attrs(self) -> None:
- """Update the entity attributes."""
- self._attr_is_closed = self._value == CLOSED
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_ChargePortDoorOpen(
+ self._async_value_from_stream
+ )
+ )
+
+ def _async_value_from_stream(self, value: bool | None) -> None:
+ """Update the value of the entity."""
+ self._attr_is_closed = None if value is None else not value
+ self.async_write_ha_state()
+
+
+class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity):
+ """Base class for the front trunk cover entities."""
+
+ _attr_device_class = CoverDeviceClass.DOOR
+ _attr_supported_features = CoverEntityFeature.OPEN
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open front trunk."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT))
self._attr_is_closed = False
self.async_write_ha_state()
+ # In the future this could be extended to add aftermarket close support through a option flow
-class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity):
- """Cover entity for the rear trunk."""
- _attr_device_class = CoverDeviceClass.DOOR
+class TeslemetryPollingFrontTrunkEntity(
+ TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity
+):
+ """Polling cover entity for the front trunk."""
def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
"""Initialize the cover."""
- super().__init__(vehicle, "vehicle_state_rt")
-
self.scoped = Scope.VEHICLE_CMDS in scopes
- self._attr_supported_features = (
- CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
- )
if not self.scoped:
self._attr_supported_features = CoverEntityFeature(0)
+ super().__init__(vehicle, "vehicle_state_ft")
def _async_update_attrs(self) -> None:
"""Update the entity attributes."""
self._attr_is_closed = self._value == CLOSED
+
+class TeslemetryStreamingFrontTrunkEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryFrontTrunkEntity, CoverRestoreEntity
+):
+ """Streaming cover entity for the front trunk."""
+
+ def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
+ """Initialize the sensor."""
+ super().__init__(vehicle, "vehicle_state_ft")
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = CoverEntityFeature(0)
+ self._attr_is_closed = None
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_TrunkFront(self._async_value_from_stream)
+ )
+
+ def _async_value_from_stream(self, value: bool | None) -> None:
+ """Update the entity attributes."""
+
+ self._attr_is_closed = None if value is None else not value
+ self.async_write_ha_state()
+
+
+class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity):
+ """Cover entity for the rear trunk."""
+
+ _attr_device_class = CoverDeviceClass.DOOR
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open rear trunk."""
if self.is_closed is not False:
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR))
self._attr_is_closed = False
self.async_write_ha_state()
@@ -194,12 +375,55 @@ class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity):
"""Close rear trunk."""
if self.is_closed is not True:
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR))
self._attr_is_closed = True
self.async_write_ha_state()
+class TeslemetryPollingRearTrunkEntity(
+ TeslemetryVehicleEntity, TeslemetryRearTrunkEntity
+):
+ """Base class for the rear trunk cover entities."""
+
+ def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
+ """Initialize the sensor."""
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = CoverEntityFeature(0)
+ super().__init__(vehicle, "vehicle_state_rt")
+
+ def _async_update_attrs(self) -> None:
+ """Update the entity attributes."""
+ self._attr_is_closed = self._value == CLOSED
+
+
+class TeslemetryStreamingRearTrunkEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryRearTrunkEntity, CoverRestoreEntity
+):
+ """Polling cover entity for the rear trunk."""
+
+ def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
+ """Initialize the cover."""
+ super().__init__(vehicle, "vehicle_state_rt")
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = CoverEntityFeature(0)
+ self._attr_is_closed = None
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_TrunkRear(self._async_value_from_stream)
+ )
+
+ def _async_value_from_stream(self, value: bool | None) -> None:
+ """Update the entity attributes."""
+
+ self._attr_is_closed = None if value is None else not value
+
+
class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity):
"""Cover entity for the sunroof."""
@@ -210,7 +434,7 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity):
_attr_entity_registry_enabled_default = False
def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None:
- """Initialize the sensor."""
+ """Initialize the cover."""
super().__init__(vehicle, "vehicle_state_sun_roof_state")
self.scoped = Scope.VEHICLE_CMDS in scopes
@@ -232,7 +456,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open sunroof."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT))
self._attr_is_closed = False
self.async_write_ha_state()
@@ -240,7 +463,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity):
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close sunroof."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE))
self._attr_is_closed = True
self.async_write_ha_state()
@@ -248,7 +470,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Close sunroof."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP))
self._attr_is_closed = False
self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py
index 2b0ffd88cc6..42c8fea8d09 100644
--- a/homeassistant/components/teslemetry/device_tracker.py
+++ b/homeassistant/components/teslemetry/device_tracker.py
@@ -2,18 +2,69 @@
from __future__ import annotations
-from homeassistant.components.device_tracker.config_entry import TrackerEntity
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from teslemetry_stream import TeslemetryStreamVehicle
+from teslemetry_stream.const import TeslaLocation
+
+from homeassistant.components.device_tracker.config_entry import (
+ TrackerEntity,
+ TrackerEntityDescription,
+)
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
-from .entity import TeslemetryVehicleEntity
+from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity
from .models import TeslemetryVehicleData
PARALLEL_UPDATES = 0
+@dataclass(frozen=True, kw_only=True)
+class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription):
+ """Describe a Teslemetry device tracker entity."""
+
+ value_listener: Callable[
+ [TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]],
+ Callable[[], None],
+ ]
+ name_listener: (
+ Callable[
+ [TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None]
+ ]
+ | None
+ ) = None
+ streaming_firmware: str
+ polling_prefix: str | None = None
+
+
+DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = (
+ TeslemetryDeviceTrackerEntityDescription(
+ key="location",
+ polling_prefix="drive_state",
+ value_listener=lambda x, y: x.listen_Location(y),
+ streaming_firmware="2024.26",
+ ),
+ TeslemetryDeviceTrackerEntityDescription(
+ key="route",
+ polling_prefix="drive_state_active_route",
+ value_listener=lambda x, y: x.listen_DestinationLocation(y),
+ name_listener=lambda x, y: x.listen_DestinationName(y),
+ streaming_firmware="2024.26",
+ ),
+ TeslemetryDeviceTrackerEntityDescription(
+ key="origin",
+ value_listener=lambda x, y: x.listen_OriginLocation(y),
+ streaming_firmware="2024.26",
+ entity_registry_enabled_default=False,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
@@ -21,67 +72,105 @@ async def async_setup_entry(
) -> None:
"""Set up the Teslemetry device tracker platform from a config entry."""
- async_add_entities(
- klass(vehicle)
- for klass in (
- TeslemetryDeviceTrackerLocationEntity,
- TeslemetryDeviceTrackerRouteEntity,
- )
- for vehicle in entry.runtime_data.vehicles
- )
+ entities: list[
+ TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity
+ ] = []
+ for vehicle in entry.runtime_data.vehicles:
+ for description in DESCRIPTIONS:
+ if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware:
+ if description.polling_prefix:
+ entities.append(
+ TeslemetryPollingDeviceTrackerEntity(vehicle, description)
+ )
+ else:
+ entities.append(
+ TeslemetryStreamingDeviceTrackerEntity(vehicle, description)
+ )
+
+ async_add_entities(entities)
-class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity):
- """Base class for Teslemetry tracker entities."""
+class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity):
+ """Base class for Teslemetry Tracker Entities."""
- lat_key: str
- lon_key: str
+ entity_description: TeslemetryDeviceTrackerEntityDescription
def __init__(
self,
vehicle: TeslemetryVehicleData,
+ description: TeslemetryDeviceTrackerEntityDescription,
) -> None:
"""Initialize the device tracker."""
- super().__init__(vehicle, self.key)
+ self.entity_description = description
+ super().__init__(vehicle, description.key)
def _async_update_attrs(self) -> None:
- """Update the attributes of the device tracker."""
-
+ """Update the attributes of the entity."""
+ self._attr_latitude = self.get(
+ f"{self.entity_description.polling_prefix}_latitude"
+ )
+ self._attr_longitude = self.get(
+ f"{self.entity_description.polling_prefix}_longitude"
+ )
+ self._attr_location_name = self.get(
+ f"{self.entity_description.polling_prefix}_destination"
+ )
+ if self._attr_location_name == "Home":
+ self._attr_location_name = STATE_HOME
self._attr_available = (
- self.get(self.lat_key, False) is not None
- and self.get(self.lon_key, False) is not None
+ self._attr_latitude is not None and self._attr_longitude is not None
)
- @property
- def latitude(self) -> float | None:
- """Return latitude value of the device."""
- return self.get(self.lat_key)
- @property
- def longitude(self) -> float | None:
- """Return longitude value of the device."""
- return self.get(self.lon_key)
+class TeslemetryStreamingDeviceTrackerEntity(
+ TeslemetryVehicleStreamEntity, TrackerEntity, RestoreEntity
+):
+ """Base class for Teslemetry Tracker Entities."""
+ entity_description: TeslemetryDeviceTrackerEntityDescription
-class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity):
- """Vehicle location device tracker class."""
+ def __init__(
+ self,
+ vehicle: TeslemetryVehicleData,
+ description: TeslemetryDeviceTrackerEntityDescription,
+ ) -> None:
+ """Initialize the device tracker."""
+ self.entity_description = description
+ super().__init__(vehicle, description.key)
- key = "location"
- lat_key = "drive_state_latitude"
- lon_key = "drive_state_longitude"
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ self._attr_state = state.state
+ self._attr_latitude = state.attributes.get("latitude")
+ self._attr_longitude = state.attributes.get("longitude")
+ self._attr_location_name = state.attributes.get("location_name")
+ self.async_on_remove(
+ self.entity_description.value_listener(
+ self.vehicle.stream_vehicle, self._location_callback
+ )
+ )
+ if self.entity_description.name_listener:
+ self.async_on_remove(
+ self.entity_description.name_listener(
+ self.vehicle.stream_vehicle, self._name_callback
+ )
+ )
+ def _location_callback(self, location: TeslaLocation | None) -> None:
+ """Update the value of the entity."""
+ if location is None:
+ self._attr_available = False
+ else:
+ self._attr_available = True
+ self._attr_latitude = location.latitude
+ self._attr_longitude = location.longitude
+ self.async_write_ha_state()
-class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity):
- """Vehicle navigation device tracker class."""
-
- key = "route"
- lat_key = "drive_state_active_route_latitude"
- lon_key = "drive_state_active_route_longitude"
-
- @property
- def location_name(self) -> str | None:
- """Return a location name for the current location of the device."""
- location = self.get("drive_state_active_route_destination")
- if location == "Home":
- return STATE_HOME
- return location
+ def _name_callback(self, name: str | None) -> None:
+ """Update the value of the entity."""
+ self._attr_location_name = name
+ if self._attr_location_name == "Home":
+ self._attr_location_name = STATE_HOME
+ self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py
index 7e9c8a9a5b0..fc601a58ae6 100644
--- a/homeassistant/components/teslemetry/diagnostics.py
+++ b/homeassistant/components/teslemetry/diagnostics.py
@@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics(
]
energysites = [
{
- "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT),
+ "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT)
+ if x.live_coordinator
+ else None,
"info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT),
}
for x in entry.runtime_data.energysites
diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py
index d14f3a42734..82d3db123c3 100644
--- a/homeassistant/components/teslemetry/entity.py
+++ b/homeassistant/components/teslemetry/entity.py
@@ -3,11 +3,14 @@
from abc import abstractmethod
from typing import Any
+from propcache.api import cached_property
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from teslemetry_stream import Signal
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -21,18 +24,33 @@ from .helpers import wake_up_vehicle
from .models import TeslemetryEnergyData, TeslemetryVehicleData
+class TeslemetryRootEntity(Entity):
+ """Parent class for all Teslemetry entities."""
+
+ _attr_has_entity_name = True
+ scoped: bool
+ api: VehicleSpecific | EnergySpecific
+
+ def raise_for_scope(self, scope: Scope):
+ """Raise an error if a scope is not available."""
+ if not self.scoped:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="missing_scope",
+ translation_placeholders={"scope": scope},
+ )
+
+
class TeslemetryEntity(
+ TeslemetryRootEntity,
CoordinatorEntity[
TeslemetryVehicleDataCoordinator
| TeslemetryEnergyHistoryCoordinator
| TeslemetryEnergySiteLiveCoordinator
| TeslemetryEnergySiteInfoCoordinator
- ]
+ ],
):
- """Parent class for all Teslemetry entities."""
-
- _attr_has_entity_name = True
- scoped: bool
+ """Parent class for all Teslemetry Coordinator entities."""
def __init__(
self,
@@ -73,11 +91,6 @@ class TeslemetryEntity(
"""Return if the value is a literal None."""
return self.get(self.key, False) is None
- @property
- def has(self) -> bool:
- """Return True if a specific value is in coordinator data."""
- return self.key in self.coordinator.data
-
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_attrs()
@@ -87,15 +100,6 @@ class TeslemetryEntity(
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
- def raise_for_scope(self, scope: Scope):
- """Raise an error if a scope is not available."""
- if not self.scoped:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="missing_scope",
- translation_placeholders={"scope": scope},
- )
-
class TeslemetryVehicleEntity(TeslemetryEntity):
"""Parent class for Teslemetry Vehicle entities."""
@@ -139,6 +143,8 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity):
) -> None:
"""Initialize common aspects of a Teslemetry Energy Site Live entity."""
+ assert data.live_coordinator
+
self.api = data.api
self._attr_unique_id = f"{data.id}-{key}"
self._attr_device_info = data.device
@@ -198,6 +204,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity):
) -> None:
"""Initialize common aspects of a Teslemetry entity."""
+ assert data.live_coordinator
+
self.api = data.api
self.din = din
self._attr_unique_id = f"{data.id}-{din}-{key}"
@@ -236,3 +244,53 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity):
return self.key in self.coordinator.data.get("wall_connectors", {}).get(
self.din, {}
)
+
+
+class TeslemetryVehicleStreamEntity(TeslemetryRootEntity):
+ """Parent class for Teslemetry Vehicle Stream entities."""
+
+ def __init__(
+ self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None
+ ) -> None:
+ """Initialize common aspects of a Teslemetry entity."""
+ self.streaming_key = streaming_key
+ self.vehicle = data
+
+ self.api = data.api
+ self.stream = data.stream
+ self.vin = data.vin
+ self.add_field = data.stream.get_vehicle(self.vin).add_field
+
+ self._attr_translation_key = key
+ self._attr_unique_id = f"{data.vin}-{key}"
+ self._attr_device_info = data.device
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ if self.streaming_key:
+ self.async_on_remove(
+ self.stream.async_add_listener(
+ self._handle_stream_update,
+ {"vin": self.vin, "data": {self.streaming_key: None}},
+ )
+ )
+ self.vehicle.config_entry.async_create_background_task(
+ self.hass,
+ self.add_field(self.streaming_key),
+ f"Adding field {self.streaming_key.value} to {self.vehicle.vin}",
+ )
+
+ def _handle_stream_update(self, data: dict[str, Any]) -> None:
+ """Handle updated data from the stream."""
+ self._async_value_from_stream(data["data"][self.streaming_key])
+ self.async_write_ha_state()
+
+ def _async_value_from_stream(self, value: Any) -> None:
+ """Update the entity with the latest value from the stream."""
+ raise NotImplementedError
+
+ @cached_property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.stream.connected
diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json
index 6559acf89dc..9996a508177 100644
--- a/homeassistant/components/teslemetry/icons.json
+++ b/homeassistant/components/teslemetry/icons.json
@@ -291,7 +291,7 @@
}
},
"switch": {
- "charge_state_user_charge_enable_request": {
+ "charge_state_charging_state": {
"default": "mdi:ev-station"
},
"climate_state_auto_seat_climate_left": {
diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py
index 4600391145b..18b88273bec 100644
--- a/homeassistant/components/teslemetry/lock.py
+++ b/homeassistant/components/teslemetry/lock.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from itertools import chain
from typing import Any
from tesla_fleet_api.const import Scope
@@ -10,10 +11,15 @@ from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
from .const import DOMAIN
-from .entity import TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_vehicle_command
from .models import TeslemetryVehicleData
@@ -30,31 +36,38 @@ async def async_setup_entry(
"""Set up the Teslemetry lock platform from a config entry."""
async_add_entities(
- klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes)
- for klass in (
- TeslemetryVehicleLockEntity,
- TeslemetryCableLockEntity,
+ chain(
+ (
+ TeslemetryPollingVehicleLockEntity(
+ vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
+ )
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.26"
+ else TeslemetryStreamingVehicleLockEntity(
+ vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
+ )
+ for vehicle in entry.runtime_data.vehicles
+ ),
+ (
+ TeslemetryPollingCableLockEntity(
+ vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
+ )
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.26"
+ else TeslemetryStreamingCableLockEntity(
+ vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
+ )
+ for vehicle in entry.runtime_data.vehicles
+ ),
)
- for vehicle in entry.runtime_data.vehicles
)
-class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity):
- """Lock entity for Teslemetry."""
-
- def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None:
- """Initialize the lock."""
- super().__init__(data, "vehicle_state_locked")
- self.scoped = scoped
-
- def _async_update_attrs(self) -> None:
- """Update entity attributes."""
- self._attr_is_locked = self._value
+class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity):
+ """Base vehicle lock entity for Teslemetry."""
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the doors."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.door_lock())
self._attr_is_locked = True
self.async_write_ha_state()
@@ -62,27 +75,65 @@ class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the doors."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.door_unlock())
self._attr_is_locked = False
self.async_write_ha_state()
-class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity):
- """Cable Lock entity for Teslemetry."""
+class TeslemetryPollingVehicleLockEntity(
+ TeslemetryVehicleEntity, TeslemetryVehicleLockEntity
+):
+ """Polling vehicle lock entity for Teslemetry."""
- def __init__(
- self,
- data: TeslemetryVehicleData,
- scoped: bool,
- ) -> None:
- """Initialize the lock."""
- super().__init__(data, "charge_state_charge_port_latch")
+ def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None:
+ """Initialize the sensor."""
+ super().__init__(
+ data,
+ "vehicle_state_locked",
+ )
self.scoped = scoped
def _async_update_attrs(self) -> None:
"""Update entity attributes."""
- self._attr_is_locked = self._value == ENGAGED
+ self._attr_is_locked = self._value
+
+
+class TeslemetryStreamingVehicleLockEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryVehicleLockEntity, RestoreEntity
+):
+ """Streaming vehicle lock entity for Teslemetry."""
+
+ def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None:
+ """Initialize the sensor."""
+ super().__init__(
+ data,
+ "vehicle_state_locked",
+ )
+ self.scoped = scoped
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ # Restore state
+ if (state := await self.async_get_last_state()) is not None:
+ if state.state == "locked":
+ self._attr_is_locked = True
+ elif state.state == "unlocked":
+ self._attr_is_locked = False
+
+ # Add streaming listener
+ self.async_on_remove(self.vehicle.stream_vehicle.listen_Locked(self._callback))
+
+ def _callback(self, value: bool | None) -> None:
+ """Update entity attributes."""
+ self._attr_is_locked = value
+ self.async_write_ha_state()
+
+
+class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity):
+ """Base cable Lock entity for Teslemetry."""
async def async_lock(self, **kwargs: Any) -> None:
"""Charge cable Lock cannot be manually locked."""
@@ -95,7 +146,70 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock charge cable lock."""
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.charge_port_door_open())
self._attr_is_locked = False
self.async_write_ha_state()
+
+
+class TeslemetryPollingCableLockEntity(
+ TeslemetryVehicleEntity, TeslemetryCableLockEntity
+):
+ """Polling cable lock entity for Teslemetry."""
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ scoped: bool,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(
+ data,
+ "charge_state_charge_port_latch",
+ )
+ self.scoped = scoped
+
+ def _async_update_attrs(self) -> None:
+ """Update entity attributes."""
+ if self._value is None:
+ self._attr_is_locked = None
+ self._attr_is_locked = self._value == ENGAGED
+
+
+class TeslemetryStreamingCableLockEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryCableLockEntity, RestoreEntity
+):
+ """Streaming cable lock entity for Teslemetry."""
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ scoped: bool,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(
+ data,
+ "charge_state_charge_port_latch",
+ )
+ self.scoped = scoped
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ # Restore state
+ if (state := await self.async_get_last_state()) is not None:
+ if state.state == "locked":
+ self._attr_is_locked = True
+ elif state.state == "unlocked":
+ self._attr_is_locked = False
+
+ # Add streaming listener
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_ChargePortLatch(self._callback)
+ )
+
+ def _callback(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_is_locked = None if value is None else value == ENGAGED
+ self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index 3736d76bf36..136990e5347 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==0.8.5", "teslemetry-stream==0.4.2"]
+ "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.6"]
}
diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py
index d3969b30a7c..5b78386c68a 100644
--- a/homeassistant/components/teslemetry/models.py
+++ b/homeassistant/components/teslemetry/models.py
@@ -8,8 +8,9 @@ from dataclasses import dataclass
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
-from teslemetry_stream import TeslemetryStream
+from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle
+from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from .coordinator import (
@@ -34,12 +35,15 @@ class TeslemetryVehicleData:
"""Data for a vehicle in the Teslemetry integration."""
api: VehicleSpecific
+ config_entry: ConfigEntry
coordinator: TeslemetryVehicleDataCoordinator
stream: TeslemetryStream
+ stream_vehicle: TeslemetryStreamVehicle
vin: str
- wakelock = asyncio.Lock()
+ firmware: str
device: DeviceInfo
remove_listener: Callable
+ wakelock = asyncio.Lock()
@dataclass
@@ -47,7 +51,7 @@ class TeslemetryEnergyData:
"""Data for a vehicle in the Teslemetry integration."""
api: EnergySpecific
- live_coordinator: TeslemetryEnergySiteLiveCoordinator
+ live_coordinator: TeslemetryEnergySiteLiveCoordinator | None
info_coordinator: TeslemetryEnergySiteInfoCoordinator
history_coordinator: TeslemetryEnergyHistoryCoordinator | None
id: int
diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py
index 9ba9c28b199..c44028f2da7 100644
--- a/homeassistant/components/teslemetry/number.py
+++ b/homeassistant/components/teslemetry/number.py
@@ -9,20 +9,33 @@ from typing import Any
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from teslemetry_stream import TeslemetryStreamVehicle
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
+ RestoreNumber,
+)
+from homeassistant.const import (
+ PERCENTAGE,
+ PRECISION_WHOLE,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ UnitOfElectricCurrent,
)
-from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from . import TeslemetryConfigEntry
-from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryEnergyInfoEntity,
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_command, handle_vehicle_command
from .models import TeslemetryEnergyData, TeslemetryVehicleData
@@ -33,12 +46,22 @@ PARALLEL_UPDATES = 0
class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription):
"""Describes Teslemetry Number entity."""
- func: Callable[[VehicleSpecific, float], Awaitable[Any]]
- native_min_value: float
- native_max_value: float
+ func: Callable[[VehicleSpecific, int], Awaitable[Any]]
min_key: str | None = None
max_key: str
+ native_min_value: float
+ native_max_value: float
scopes: list[Scope]
+ value_listener: Callable[
+ [TeslemetryStreamVehicle, Callable[[int | None], None]],
+ Callable[[], None],
+ ]
+ max_listener: (
+ Callable[
+ [TeslemetryStreamVehicle, Callable[[int | None], None]], Callable[[], None]
+ ]
+ | None
+ ) = None
VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = (
@@ -52,7 +75,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = (
mode=NumberMode.AUTO,
max_key="charge_state_charge_current_request_max",
func=lambda api, value: api.set_charging_amps(value),
- scopes=[Scope.VEHICLE_CHARGING_CMDS],
+ scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS],
+ value_listener=lambda x, y: x.listen_ChargeCurrentRequest(y),
+ max_listener=lambda x, y: x.listen_ChargeCurrentRequestMax(y),
),
TeslemetryNumberVehicleEntityDescription(
key="charge_state_charge_limit_soc",
@@ -62,10 +87,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
device_class=NumberDeviceClass.BATTERY,
mode=NumberMode.AUTO,
- min_key="charge_state_charge_limit_soc_min",
max_key="charge_state_charge_limit_soc_max",
func=lambda api, value: api.set_charge_limit(value),
scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS],
+ value_listener=lambda x, y: x.listen_ChargeLimitSoc(y),
),
)
@@ -76,16 +101,29 @@ class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription):
func: Callable[[EnergySpecific, float], Awaitable[Any]]
requires: str | None = None
+ scopes: list[Scope]
ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = (
TeslemetryNumberBatteryEntityDescription(
key="backup_reserve_percent",
+ native_step=PRECISION_WHOLE,
+ native_min_value=0,
+ native_max_value=100,
+ device_class=NumberDeviceClass.BATTERY,
+ native_unit_of_measurement=PERCENTAGE,
+ scopes=[Scope.ENERGY_CMDS],
func=lambda api, value: api.backup(int(value)),
requires="components_battery",
),
TeslemetryNumberBatteryEntityDescription(
key="off_grid_vehicle_charging_reserve_percent",
+ native_step=PRECISION_WHOLE,
+ native_min_value=0,
+ native_max_value=100,
+ device_class=NumberDeviceClass.BATTERY,
+ native_unit_of_measurement=PERCENTAGE,
+ scopes=[Scope.ENERGY_CMDS],
func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)),
requires="components_off_grid_vehicle_charging_reserve_supported",
),
@@ -101,8 +139,14 @@ async def async_setup_entry(
async_add_entities(
chain(
- ( # Add vehicle entities
- TeslemetryVehicleNumberEntity(
+ (
+ TeslemetryPollingNumberEntity(
+ vehicle,
+ description,
+ entry.runtime_data.scopes,
+ )
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.26"
+ else TeslemetryStreamingNumberEntity(
vehicle,
description,
entry.runtime_data.scopes,
@@ -110,7 +154,7 @@ async def async_setup_entry(
for vehicle in entry.runtime_data.vehicles
for description in VEHICLE_DESCRIPTIONS
),
- ( # Add energy site entities
+ (
TeslemetryEnergyInfoNumberSensorEntity(
energysite,
description,
@@ -125,11 +169,25 @@ async def async_setup_entry(
)
-class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity):
+class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity):
"""Vehicle number entity base class."""
entity_description: TeslemetryNumberVehicleEntityDescription
+ async def async_set_native_value(self, value: float) -> None:
+ """Set new value."""
+ value = int(value)
+ self.raise_for_scope(self.entity_description.scopes[0])
+ await handle_vehicle_command(self.entity_description.func(self.api, value))
+ self._attr_native_value = value
+ self.async_write_ha_state()
+
+
+class TeslemetryPollingNumberEntity(
+ TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity
+):
+ """Vehicle polling number entity."""
+
def __init__(
self,
data: TeslemetryVehicleData,
@@ -148,26 +206,67 @@ class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity):
"""Update the attributes of the entity."""
self._attr_native_value = self._value
- if (min_key := self.entity_description.min_key) is not None:
- self._attr_native_min_value = self.get_number(
- min_key,
- self.entity_description.native_min_value,
- )
- else:
- self._attr_native_min_value = self.entity_description.native_min_value
-
self._attr_native_max_value = self.get_number(
self.entity_description.max_key,
self.entity_description.native_max_value,
)
- async def async_set_native_value(self, value: float) -> None:
- """Set new value."""
- value = int(value)
- self.raise_for_scope(self.entity_description.scopes[0])
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.entity_description.func(self.api, value))
- self._attr_native_value = value
+
+class TeslemetryStreamingNumberEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryVehicleNumberEntity, RestoreNumber
+):
+ """Number entity for current charge."""
+
+ entity_description: TeslemetryNumberVehicleEntityDescription
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ description: TeslemetryNumberVehicleEntityDescription,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the Number entity."""
+ self.scoped = any(scope in scopes for scope in description.scopes)
+ self.entity_description = description
+ self._attr_native_max_value = self.entity_description.native_max_value
+ super().__init__(data, description.key)
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ # Restore state
+ if (last_state := await self.async_get_last_state()) and (
+ last_number_data := await self.async_get_last_number_data()
+ ):
+ if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ self._attr_native_value = last_number_data.native_value
+ if last_number_data.native_max_value:
+ self._attr_native_max_value = last_number_data.native_max_value
+
+ # Add listeners
+ self.async_on_remove(
+ self.entity_description.value_listener(
+ self.vehicle.stream_vehicle, self._value_callback
+ )
+ )
+ if self.entity_description.max_listener:
+ self.async_on_remove(
+ self.entity_description.max_listener(
+ self.vehicle.stream_vehicle, self._max_callback
+ )
+ )
+
+ def _value_callback(self, value: int | None) -> None:
+ """Update the value of the entity."""
+ self._attr_native_value = None if value is None else value
+ self.async_write_ha_state()
+
+ def _max_callback(self, value: int | None) -> None:
+ """Update the value of the entity."""
+ self._attr_native_max_value = (
+ self.entity_description.native_max_value if value is None else value
+ )
self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py
index 95876cc2cf9..dd83ad04ed6 100644
--- a/homeassistant/components/teslemetry/sensor.py
+++ b/homeassistant/components/teslemetry/sensor.py
@@ -4,11 +4,13 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from datetime import timedelta
-from itertools import chain
-from typing import cast
+from datetime import datetime, timedelta
+
+from propcache.api import cached_property
+from teslemetry_stream import Signal
from homeassistant.components.sensor import (
+ RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -40,6 +42,7 @@ from .entity import (
TeslemetryEnergyInfoEntity,
TeslemetryEnergyLiveEntity,
TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
TeslemetryWallConnectorEntity,
)
from .models import TeslemetryEnergyData, TeslemetryVehicleData
@@ -59,125 +62,165 @@ SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"}
@dataclass(frozen=True, kw_only=True)
-class TeslemetrySensorEntityDescription(SensorEntityDescription):
+class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription):
"""Describes Teslemetry Sensor entity."""
- value_fn: Callable[[StateType], StateType] = lambda x: x
+ polling: bool = False
+ polling_value_fn: Callable[[StateType], StateType] = lambda x: x
+ polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None
+ streaming_key: Signal | None = None
+ streaming_value_fn: Callable[[StateType], StateType] = lambda x: x
+ streaming_firmware: str = "2024.26"
-VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
- TeslemetrySensorEntityDescription(
+VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_charging_state",
+ polling=True,
+ streaming_key=Signal.DETAILED_CHARGE_STATE,
+ polling_value_fn=lambda value: CHARGE_STATES.get(str(value)),
+ streaming_value_fn=lambda value: CHARGE_STATES.get(
+ str(value).replace("DetailedChargeState", "")
+ ),
options=list(CHARGE_STATES.values()),
device_class=SensorDeviceClass.ENUM,
- value_fn=lambda value: CHARGE_STATES.get(cast(str, value)),
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_battery_level",
+ polling=True,
+ streaming_key=Signal.BATTERY_LEVEL,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
+ suggested_display_precision=1,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_usable_battery_level",
+ polling=True,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_charge_energy_added",
+ polling=True,
+ streaming_key=Signal.AC_CHARGING_ENERGY_IN,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=1,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_power",
+ polling=True,
+ streaming_key=Signal.AC_CHARGING_POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_voltage",
+ polling=True,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_actual_current",
+ polling=True,
+ streaming_key=Signal.CHARGE_AMPS,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_charge_rate",
+ polling=True,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
entity_category=EntityCategory.DIAGNOSTIC,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_conn_charge_cable",
+ polling=True,
+ streaming_key=Signal.CHARGING_CABLE_TYPE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_fast_charger_type",
+ polling=True,
+ streaming_key=Signal.FAST_CHARGER_TYPE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_battery_range",
+ polling=True,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_est_battery_range",
+ polling=True,
+ streaming_key=Signal.EST_BATTERY_RANGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="charge_state_ideal_battery_range",
+ polling=True,
+ streaming_key=Signal.IDEAL_BATTERY_RANGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="drive_state_speed",
+ polling=True,
+ polling_value_fn=lambda value: value or 0,
+ streaming_key=Signal.VEHICLE_SPEED,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
entity_registry_enabled_default=False,
- value_fn=lambda value: value or 0,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="drive_state_power",
+ polling=True,
+ polling_value_fn=lambda value: value or 0,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda value: value or 0,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="drive_state_shift_state",
+ polling=True,
+ polling_available_fn=lambda x: True,
+ polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
+ streaming_key=Signal.GEAR,
+ streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)),
options=list(SHIFT_STATES.values()),
device_class=SensorDeviceClass.ENUM,
- value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_odometer",
+ polling=True,
+ streaming_key=Signal.ODOMETER,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@@ -185,8 +228,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_fl",
+ polling=True,
+ streaming_key=Signal.TPMS_PRESSURE_FL,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -195,8 +240,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_fr",
+ polling=True,
+ streaming_key=Signal.TPMS_PRESSURE_FR,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -205,8 +252,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_rl",
+ polling=True,
+ streaming_key=Signal.TPMS_PRESSURE_RL,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -215,8 +264,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_rr",
+ polling=True,
+ streaming_key=Signal.TPMS_PRESSURE_RR,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -225,22 +276,27 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="climate_state_inside_temp",
+ polling=True,
+ streaming_key=Signal.INSIDE_TEMP,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="climate_state_outside_temp",
+ polling=True,
+ streaming_key=Signal.OUTSIDE_TEMP,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="climate_state_driver_temp_setting",
+ polling=True,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
@@ -248,8 +304,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="climate_state_passenger_temp_setting",
+ polling=True,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
@@ -257,23 +314,29 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_traffic_minutes_delay",
+ polling=True,
+ streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_energy_at_arrival",
+ polling=True,
+ streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- TeslemetrySensorEntityDescription(
+ TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_miles_to_arrival",
+ polling=True,
+ streaming_key=Signal.MILES_TO_ARRIVAL,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@@ -286,24 +349,36 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription):
"""Describes Teslemetry Sensor entity."""
variance: int
+ streaming_key: Signal
+ streaming_firmware: str = "2024.26"
VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = (
TeslemetryTimeEntityDescription(
key="charge_state_minutes_to_full_charge",
+ streaming_key=Signal.TIME_TO_FULL_CHARGE,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
variance=4,
),
TeslemetryTimeEntityDescription(
key="drive_state_active_route_minutes_to_arrival",
+ streaming_key=Signal.MINUTES_TO_ARRIVAL,
device_class=SensorDeviceClass.TIMESTAMP,
variance=1,
),
)
-ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
- SensorEntityDescription(
+
+@dataclass(frozen=True, kw_only=True)
+class TeslemetryEnergySensorEntityDescription(SensorEntityDescription):
+ """Describes Teslemetry Sensor entity."""
+
+ value_fn: Callable[[StateType], StateType | datetime] = lambda x: x
+
+
+ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryEnergySensorEntityDescription, ...] = (
+ TeslemetryEnergySensorEntityDescription(
key="solar_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -311,7 +386,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="energy_left",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -320,7 +395,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="total_pack_energy",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -330,14 +405,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="percentage_charged",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
suggested_display_precision=2,
+ value_fn=lambda value: value or 0,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="battery_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -345,7 +421,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="load_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -353,7 +429,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="grid_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -361,7 +437,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="grid_services_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -369,7 +445,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="generator_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -378,7 +454,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
),
- SensorEntityDescription(
+ TeslemetryEnergySensorEntityDescription(
key="island_status",
device_class=SensorDeviceClass.ENUM,
options=[
@@ -391,6 +467,14 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
),
)
+
+@dataclass(frozen=True, kw_only=True)
+class TeslemetrySensorEntityDescription(SensorEntityDescription):
+ """Describes Teslemetry Sensor entity."""
+
+ value_fn: Callable[[StateType], StateType] = lambda x: x
+
+
WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
TeslemetrySensorEntityDescription(
key="wall_connector_state",
@@ -448,55 +532,110 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Teslemetry sensor platform from a config entry."""
- async_add_entities(
- chain(
- ( # Add vehicles
- TeslemetryVehicleSensorEntity(vehicle, description)
- for vehicle in entry.runtime_data.vehicles
- for description in VEHICLE_DESCRIPTIONS
- ),
- ( # Add vehicles time sensors
- TeslemetryVehicleTimeSensorEntity(vehicle, description)
- for vehicle in entry.runtime_data.vehicles
- for description in VEHICLE_TIME_DESCRIPTIONS
- ),
- ( # Add energy site live
- TeslemetryEnergyLiveSensorEntity(energysite, description)
- for energysite in entry.runtime_data.energysites
- for description in ENERGY_LIVE_DESCRIPTIONS
- if description.key in energysite.live_coordinator.data
- ),
- ( # Add wall connectors
- TeslemetryWallConnectorSensorEntity(energysite, din, description)
- for energysite in entry.runtime_data.energysites
- for din in energysite.live_coordinator.data.get("wall_connectors", {})
- for description in WALL_CONNECTOR_DESCRIPTIONS
- ),
- ( # Add energy site info
- TeslemetryEnergyInfoSensorEntity(energysite, description)
- for energysite in entry.runtime_data.energysites
- for description in ENERGY_INFO_DESCRIPTIONS
- if description.key in energysite.info_coordinator.data
- ),
- ( # Add energy history sensor
- TeslemetryEnergyHistorySensorEntity(energysite, description)
- for energysite in entry.runtime_data.energysites
- for description in ENERGY_HISTORY_DESCRIPTIONS
- if energysite.history_coordinator
- ),
- )
+
+ entities: list[SensorEntity] = []
+ for vehicle in entry.runtime_data.vehicles:
+ for description in VEHICLE_DESCRIPTIONS:
+ if (
+ not vehicle.api.pre2021
+ and description.streaming_key
+ and vehicle.firmware >= description.streaming_firmware
+ ):
+ entities.append(TeslemetryStreamSensorEntity(vehicle, description))
+ elif description.polling:
+ entities.append(TeslemetryVehicleSensorEntity(vehicle, description))
+
+ for time_description in VEHICLE_TIME_DESCRIPTIONS:
+ if (
+ not vehicle.api.pre2021
+ and vehicle.firmware >= time_description.streaming_firmware
+ ):
+ entities.append(
+ TeslemetryStreamTimeSensorEntity(vehicle, time_description)
+ )
+ else:
+ entities.append(
+ TeslemetryVehicleTimeSensorEntity(vehicle, time_description)
+ )
+
+ entities.extend(
+ TeslemetryEnergyLiveSensorEntity(energysite, description)
+ for energysite in entry.runtime_data.energysites
+ if energysite.live_coordinator
+ for description in ENERGY_LIVE_DESCRIPTIONS
+ if description.key in energysite.live_coordinator.data
+ or description.key == "percentage_charged"
)
+ entities.extend(
+ TeslemetryWallConnectorSensorEntity(energysite, din, description)
+ for energysite in entry.runtime_data.energysites
+ if energysite.live_coordinator
+ for din in energysite.live_coordinator.data.get("wall_connectors", {})
+ for description in WALL_CONNECTOR_DESCRIPTIONS
+ )
+
+ entities.extend(
+ TeslemetryEnergyInfoSensorEntity(energysite, description)
+ for energysite in entry.runtime_data.energysites
+ for description in ENERGY_INFO_DESCRIPTIONS
+ if description.key in energysite.info_coordinator.data
+ )
+
+ entities.extend(
+ TeslemetryEnergyHistorySensorEntity(energysite, description)
+ for energysite in entry.runtime_data.energysites
+ for description in ENERGY_HISTORY_DESCRIPTIONS
+ if energysite.history_coordinator is not None
+ )
+
+ async_add_entities(entities)
+
+
+class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor):
+ """Base class for Teslemetry vehicle streaming sensors."""
+
+ entity_description: TeslemetryVehicleSensorEntityDescription
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ description: TeslemetryVehicleSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ self.entity_description = description
+ assert description.streaming_key
+ super().__init__(data, description.key, description.streaming_key)
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ if (sensor_data := await self.async_get_last_sensor_data()) is not None:
+ self._attr_native_value = sensor_data.native_value
+
+ @cached_property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.stream.connected
+
+ def _async_value_from_stream(self, value) -> None:
+ """Update the value of the entity."""
+ if value is None:
+ self._attr_native_value = None
+ else:
+ self._attr_native_value = self.entity_description.streaming_value_fn(value)
+
class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
"""Base class for Teslemetry vehicle metric sensors."""
- entity_description: TeslemetrySensorEntityDescription
+ entity_description: TeslemetryVehicleSensorEntityDescription
def __init__(
self,
data: TeslemetryVehicleData,
- description: TeslemetrySensorEntityDescription,
+ description: TeslemetryVehicleSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
@@ -504,12 +643,48 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
- if self.has:
- self._attr_native_value = self.entity_description.value_fn(self._value)
+ if self.entity_description.polling_available_fn(self._value):
+ self._attr_available = True
+ self._attr_native_value = self.entity_description.polling_value_fn(
+ self._value
+ )
else:
+ self._attr_available = False
self._attr_native_value = None
+class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEntity):
+ """Base class for Teslemetry vehicle streaming sensors."""
+
+ entity_description: TeslemetryTimeEntityDescription
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ description: TeslemetryTimeEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ self.entity_description = description
+ self._get_timestamp = ignore_variance(
+ func=lambda value: dt_util.now() + timedelta(minutes=value),
+ ignored_variance=timedelta(minutes=description.variance),
+ )
+ assert description.streaming_key
+ super().__init__(data, description.key, description.streaming_key)
+
+ @cached_property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.stream.connected
+
+ def _async_value_from_stream(self, value) -> None:
+ """Update the value of the entity."""
+ if value is None:
+ self._attr_native_value = None
+ else:
+ self._attr_native_value = self._get_timestamp(value)
+
+
class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):
"""Base class for Teslemetry vehicle time sensors."""
@@ -539,12 +714,12 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):
class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity):
"""Base class for Teslemetry energy site metric sensors."""
- entity_description: SensorEntityDescription
+ entity_description: TeslemetryEnergySensorEntityDescription
def __init__(
self,
data: TeslemetryEnergyData,
- description: SensorEntityDescription,
+ description: TeslemetryEnergySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
@@ -553,7 +728,7 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity)
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
self._attr_available = not self.is_none
- self._attr_native_value = self._value
+ self._attr_native_value = self.entity_description.value_fn(self._value)
class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity):
diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py
index 97cfffa1699..8215adb5711 100644
--- a/homeassistant/components/teslemetry/services.py
+++ b/homeassistant/components/teslemetry/services.py
@@ -98,7 +98,7 @@ def async_get_energy_site_for_entry(
return energy_data
-def async_register_services(hass: HomeAssistant) -> None: # noqa: C901
+def async_register_services(hass: HomeAssistant) -> None:
"""Set up the Teslemetry services."""
async def navigate_gps_request(call: ServiceCall) -> None:
diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json
index 4f4bc2ae60c..68ad12a46b6 100644
--- a/homeassistant/components/teslemetry/strings.json
+++ b/homeassistant/components/teslemetry/strings.json
@@ -51,7 +51,7 @@
"name": "Trip charging"
},
"climate_state_cabin_overheat_protection_actively_cooling": {
- "name": "Cabin overheat protection actively cooling"
+ "name": "Cabin overheat protection active"
},
"climate_state_is_preconditioning": {
"name": "Preconditioning"
@@ -68,6 +68,27 @@
"storm_mode_active": {
"name": "Storm watch active"
},
+ "automatic_blind_spot_camera": {
+ "name": "Automatic blind spot camera"
+ },
+ "automatic_emergency_braking_off": {
+ "name": "Automatic emergency braking off"
+ },
+ "blind_spot_collision_warning_chime": {
+ "name": "Blind spot collision warning chime"
+ },
+ "bms_full_charge_complete": {
+ "name": "BMS full charge"
+ },
+ "brake_pedal": {
+ "name": "Brake pedal"
+ },
+ "charge_port_cold_weather_mode": {
+ "name": "Charge port cold weather mode"
+ },
+ "service_mode": {
+ "name": "Service mode"
+ },
"vehicle_state_dashcam_state": {
"name": "Dashcam"
},
@@ -109,6 +130,66 @@
},
"vehicle_state_tpms_soft_warning_rr": {
"name": "Tire pressure warning rear right"
+ },
+ "pin_to_drive_enabled": {
+ "name": "Pin to drive enabled"
+ },
+ "drive_rail": {
+ "name": "Drive rail"
+ },
+ "driver_seat_belt": {
+ "name": "Driver seat belt"
+ },
+ "driver_seat_occupied": {
+ "name": "Driver seat occupied"
+ },
+ "passenger_seat_belt": {
+ "name": "Passenger seat belt"
+ },
+ "fast_charger_present": {
+ "name": "Fast charger present"
+ },
+ "gps_state": {
+ "name": "GPS state"
+ },
+ "guest_mode_enabled": {
+ "name": "Guest mode enabled"
+ },
+ "dc_dc_enable": {
+ "name": "DC to DC converter"
+ },
+ "emergency_lane_departure_avoidance": {
+ "name": "Emergency lane departure avoidance"
+ },
+ "supercharger_session_trip_planner": {
+ "name": "Supercharger session trip planner"
+ },
+ "wiper_heat_enabled": {
+ "name": "Wiper heat"
+ },
+ "rear_display_hvac_enabled": {
+ "name": "Rear display HVAC"
+ },
+ "offroad_lightbar_present": {
+ "name": "Offroad lightbar"
+ },
+ "homelink_nearby": {
+ "name": "Homelink nearby"
+ },
+ "europe_vehicle": {
+ "name": "European vehicle"
+ },
+ "right_hand_drive": {
+ "name": "Right hand drive"
+ },
+ "located_at_home": {
+ "name": "Located at home"
+ },
+ "located_at_work": {
+ "name": "Located at work"
+ },
+ "located_at_favorite": {
+ "name": "Located at favorite"
}
},
"button": {
@@ -155,6 +236,9 @@
},
"route": {
"name": "Route"
+ },
+ "origin": {
+ "name": "Origin"
}
},
"lock": {
@@ -524,7 +608,7 @@
}
},
"switch": {
- "charge_state_user_charge_enable_request": {
+ "charge_state_charging_state": {
"name": "Charge"
},
"climate_state_auto_seat_climate_left": {
@@ -610,7 +694,7 @@
},
"services": {
"navigation_gps_request": {
- "description": "Set vehicle navigation to the provided latitude/longitude coordinates.",
+ "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.",
"fields": {
"device_id": {
"description": "Vehicle to share to.",
@@ -646,7 +730,7 @@
"name": "Set scheduled charging"
},
"set_scheduled_departure": {
- "description": "Sets a time at which departure should be completed.",
+ "description": "Sets the departure time for a vehicle to schedule charging and preconditioning.",
"fields": {
"departure_time": {
"description": "Time to be preconditioned by.",
@@ -684,7 +768,7 @@
"name": "Set scheduled departure"
},
"speed_limit": {
- "description": "Activate the speed limit of the vehicle.",
+ "description": "Activates the speed limit of a vehicle.",
"fields": {
"device_id": {
"description": "Vehicle to limit.",
@@ -702,7 +786,7 @@
"name": "Set speed limit"
},
"time_of_use": {
- "description": "Update the time of use settings for the energy site.",
+ "description": "Updates the time of use settings for an energy site.",
"fields": {
"device_id": {
"description": "Energy Site to configure.",
@@ -716,7 +800,7 @@
"name": "Time of use settings"
},
"valet_mode": {
- "description": "Activate the valet mode of the vehicle.",
+ "description": "Activates the valet mode of a vehicle.",
"fields": {
"device_id": {
"description": "Vehicle to limit.",
diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py
index 6a1cff4c5da..f810dee8554 100644
--- a/homeassistant/components/teslemetry/switch.py
+++ b/homeassistant/components/teslemetry/switch.py
@@ -16,6 +16,7 @@ from homeassistant.components.switch import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
from . import TeslemetryConfigEntry
from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity
@@ -32,6 +33,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
on_func: Callable
off_func: Callable
scopes: list[Scope]
+ value_func: Callable[[StateType], bool] = bool
+ unique_id: str | None = None
VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
@@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
scopes=[Scope.VEHICLE_CMDS],
),
-)
-
-VEHICLE_CHARGE_DESCRIPTION = TeslemetrySwitchEntityDescription(
- key="charge_state_user_charge_enable_request",
- on_func=lambda api: api.charge_start(),
- off_func=lambda api: api.charge_stop(),
- scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS],
+ TeslemetrySwitchEntityDescription(
+ key="charge_state_charging_state",
+ unique_id="charge_state_user_charge_enable_request",
+ on_func=lambda api: api.charge_start(),
+ off_func=lambda api: api.charge_stop(),
+ value_func=lambda state: state in {"Starting", "Charging"},
+ scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS],
+ ),
)
@@ -104,12 +108,6 @@ async def async_setup_entry(
for description in VEHICLE_DESCRIPTIONS
if description.key in vehicle.coordinator.data
),
- (
- TeslemetryChargeSwitchEntity(
- vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes
- )
- for vehicle in entry.runtime_data.vehicles
- ),
(
TeslemetryChargeFromGridSwitchEntity(
energysite,
@@ -145,13 +143,15 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt
scopes: list[Scope],
) -> None:
"""Initialize the Switch."""
- super().__init__(data, description.key)
self.entity_description = description
self.scoped = any(scope in scopes for scope in description.scopes)
+ super().__init__(data, description.key)
+ if description.unique_id:
+ self._attr_unique_id = f"{data.vin}-{description.unique_id}"
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
- self._attr_is_on = bool(self._value)
+ self._attr_is_on = self.entity_description.value_func(self._value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the Switch."""
@@ -170,17 +170,6 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt
self.async_write_ha_state()
-class TeslemetryChargeSwitchEntity(TeslemetryVehicleSwitchEntity):
- """Entity class for Teslemetry charge switch."""
-
- def _async_update_attrs(self) -> None:
- """Update the attributes of the entity."""
- if self._value is None:
- self._attr_is_on = self.get("charge_state_charge_enable_request")
- else:
- self._attr_is_on = self._value
-
-
class TeslemetryChargeFromGridSwitchEntity(
TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity
):
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index 2b8ae924fe3..ef4d366c779 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
- "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.5"]
+ "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"]
}
diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py
index 7f09cef2acd..323fa76ef1f 100644
--- a/homeassistant/components/tessie/sensor.py
+++ b/homeassistant/components/tessie/sensor.py
@@ -258,6 +258,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
),
)
+
ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
TessieSensorEntityDescription(
key="solar_power",
@@ -292,6 +293,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
suggested_display_precision=2,
+ value_fn=lambda value: value or 0,
),
TessieSensorEntityDescription(
key="battery_power",
diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json
index 4ac645a0270..8384bb3d8fb 100644
--- a/homeassistant/components/tessie/strings.json
+++ b/homeassistant/components/tessie/strings.json
@@ -459,7 +459,7 @@
}
},
"switch": {
- "charge_state_charge_enable_request": {
+ "charge_state_charging_state": {
"name": "Charge"
},
"climate_state_defrost_mode": {
diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py
index f0088a4444f..dba00a85bb2 100644
--- a/homeassistant/components/tessie/switch.py
+++ b/homeassistant/components/tessie/switch.py
@@ -27,6 +27,7 @@ from homeassistant.components.switch import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
from . import TessieConfigEntry
from .entity import TessieEnergyEntity, TessieEntity
@@ -40,6 +41,8 @@ class TessieSwitchEntityDescription(SwitchEntityDescription):
on_func: Callable
off_func: Callable
+ value_func: Callable[[StateType], bool] = bool
+ unique_id: str | None = None
DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = (
@@ -63,12 +66,13 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = (
on_func=lambda: start_steering_wheel_heater,
off_func=lambda: stop_steering_wheel_heater,
),
-)
-
-CHARGE_DESCRIPTION: TessieSwitchEntityDescription = TessieSwitchEntityDescription(
- key="charge_state_charge_enable_request",
- on_func=lambda: start_charging,
- off_func=lambda: stop_charging,
+ TessieSwitchEntityDescription(
+ key="charge_state_charging_state",
+ unique_id="charge_state_charge_enable_request",
+ on_func=lambda: start_charging,
+ off_func=lambda: stop_charging,
+ value_func=lambda state: state in {"Starting", "Charging"},
+ ),
)
PARALLEL_UPDATES = 0
@@ -89,10 +93,6 @@ async def async_setup_entry(
for description in DESCRIPTIONS
if description.key in vehicle.data_coordinator.data
),
- (
- TessieChargeSwitchEntity(vehicle, CHARGE_DESCRIPTION)
- for vehicle in entry.runtime_data.vehicles
- ),
(
TessieChargeFromGridSwitchEntity(energysite)
for energysite in entry.runtime_data.energysites
@@ -120,13 +120,15 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity):
description: TessieSwitchEntityDescription,
) -> None:
"""Initialize the Switch."""
- super().__init__(vehicle, description.key)
self.entity_description = description
+ super().__init__(vehicle, description.key)
+ if description.unique_id:
+ self._attr_unique_id = f"{vehicle.vin}-{description.unique_id}"
@property
def is_on(self) -> bool:
"""Return the state of the Switch."""
- return self._value
+ return self.entity_description.value_func(self._value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the Switch."""
@@ -139,18 +141,6 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity):
self.set((self.entity_description.key, False))
-class TessieChargeSwitchEntity(TessieSwitchEntity):
- """Entity class for Tessie charge switch."""
-
- @property
- def is_on(self) -> bool:
- """Return the state of the Switch."""
-
- if (charge := self.get("charge_state_user_charge_enable_request")) is not None:
- return charge
- return self._value
-
-
class TessieChargeFromGridSwitchEntity(TessieEnergyEntity, SwitchEntity):
"""Entity class for Charge From Grid switch."""
diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py
index d0f5ac7d3b7..27af7e3fe59 100644
--- a/homeassistant/components/text/__init__.py
+++ b/homeassistant/components/text/__init__.py
@@ -9,7 +9,7 @@ import logging
import re
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py
index 94269ac12fb..b1eca1e36b6 100644
--- a/homeassistant/components/text/device_action.py
+++ b/homeassistant/components/text/device_action.py
@@ -13,8 +13,7 @@ from homeassistant.const import (
CONF_TYPE,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE
diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py
index e3aa9060787..9571597abe6 100644
--- a/homeassistant/components/tfiac/climate.py
+++ b/homeassistant/components/tfiac/climate.py
@@ -26,7 +26,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/thermopro/config_flow.py b/homeassistant/components/thermopro/config_flow.py
index 4d080c6e074..4c6d59473c2 100644
--- a/homeassistant/components/thermopro/config_flow.py
+++ b/homeassistant/components/thermopro/config_flow.py
@@ -72,7 +72,7 @@ class ThermoProConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json
index 51348afb0a4..2c066d785ca 100644
--- a/homeassistant/components/thermopro/manifest.json
+++ b/homeassistant/components/thermopro/manifest.json
@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/thermopro",
"iot_class": "local_push",
- "requirements": ["thermopro-ble==0.10.0"]
+ "requirements": ["thermopro-ble==0.10.1"]
}
diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py
index 7dc845ecf60..7ce0dfb9993 100644
--- a/homeassistant/components/thermoworks_smoke/sensor.py
+++ b/homeassistant/components/thermoworks_smoke/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json
index f5a4fcef8fd..8b3eb7b53c4 100644
--- a/homeassistant/components/thethingsnetwork/strings.json
+++ b/homeassistant/components/thethingsnetwork/strings.json
@@ -2,12 +2,12 @@
"config": {
"step": {
"user": {
- "title": "Connect to The Things Network v3 App",
- "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.",
+ "title": "Connect to The Things Network v3",
+ "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.",
"data": {
- "hostname": "[%key:common::config_flow::data::host%]",
+ "host": "[%key:common::config_flow::data::host%]",
"app_id": "Application ID",
- "access_key": "[%key:common::config_flow::data::api_key%]"
+ "api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"reauth_confirm": {
diff --git a/homeassistant/components/thingspeak/__init__.py b/homeassistant/components/thingspeak/__init__.py
index fdf06a9709a..1798e4f1de0 100644
--- a/homeassistant/components/thingspeak/__init__.py
+++ b/homeassistant/components/thingspeak/__init__.py
@@ -14,8 +14,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import event, state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, event, state as state_helper
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py
index 4d28912e20d..ccdc1ada48e 100644
--- a/homeassistant/components/thinkingcleaner/sensor.py
+++ b/homeassistant/components/thinkingcleaner/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_HOST, PERCENTAGE
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py
index 76c7cdb0db2..8397eeedc23 100644
--- a/homeassistant/components/thinkingcleaner/switch.py
+++ b/homeassistant/components/thinkingcleaner/switch.py
@@ -17,7 +17,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py
index abf3e604472..f003264b6d7 100644
--- a/homeassistant/components/thomson/device_tracker.py
+++ b/homeassistant/components/thomson/device_tracker.py
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
import re
-import telnetlib # pylint: disable=deprecated-module
+import telnetlib # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.device_tracker import (
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py
index 568b76d4999..bf202a50c34 100644
--- a/homeassistant/components/thread/config_flow.py
+++ b/homeassistant/components/thread/config_flow.py
@@ -4,8 +4,9 @@ from __future__ import annotations
from typing import Any
-from homeassistant.components import onboarding, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -28,7 +29,7 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Thread", data={})
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Set up because the user has border routers."""
await self._async_handle_discovery_without_unique_id()
diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py
index fc95e524181..1b4ae7ba01f 100644
--- a/homeassistant/components/thread/dataset_store.py
+++ b/homeassistant/components/thread/dataset_store.py
@@ -8,7 +8,7 @@ from datetime import datetime
import logging
from typing import Any, cast
-from propcache import cached_property
+from propcache.api import cached_property
from python_otbr_api import tlv_parser
from python_otbr_api.tlv_parser import MeshcopTLVType
diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json
index 65d4c9d044c..868ced022b8 100644
--- a/homeassistant/components/thread/manifest.json
+++ b/homeassistant/components/thread/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"],
+ "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"],
"zeroconf": ["_meshcop._udp.local."]
}
diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py
index 9b5c7ee1168..424b35b963b 100644
--- a/homeassistant/components/tibber/__init__.py
+++ b/homeassistant/components/tibber/__init__.py
@@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py
index 26ffc0e7b6d..a3961cbb569 100644
--- a/homeassistant/components/tikteck/light.py
+++ b/homeassistant/components/tikteck/light.py
@@ -17,10 +17,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py
index 594c4e7bdcb..4b7dc9ca3b5 100644
--- a/homeassistant/components/tile/__init__.py
+++ b/homeassistant/components/tile/__init__.py
@@ -2,70 +2,28 @@
from __future__ import annotations
-from dataclasses import dataclass
-from datetime import timedelta
-from functools import partial
-
from pytile import async_login
-from pytile.errors import InvalidAuthError, SessionExpiredError, TileError
-from pytile.tile import Tile
+from pytile.errors import InvalidAuthError, TileError
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.async_ import gather_with_limited_concurrency
-from .const import DOMAIN, LOGGER
+from .coordinator import TileConfigEntry, TileCoordinator
-PLATFORMS = [Platform.DEVICE_TRACKER]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER]
DEVICE_TYPES = ["PHONE", "TILE"]
DEFAULT_INIT_TASK_LIMIT = 2
-DEFAULT_UPDATE_INTERVAL = timedelta(minutes=2)
CONF_SHOW_INACTIVE = "show_inactive"
-@dataclass
-class TileData:
- """Define an object to be stored in `hass.data`."""
-
- coordinators: dict[str, DataUpdateCoordinator[None]]
- tiles: dict[str, Tile]
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: TileConfigEntry) -> bool:
"""Set up Tile as config entry."""
- @callback
- def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None:
- """Define a callback to migrate appropriate Tile entities to new unique IDs.
-
- Old: tile_{uuid}
- New: {username}_{uuid}
- """
- if entity_entry.unique_id.startswith(entry.data[CONF_USERNAME]):
- return None
-
- new_unique_id = f"{entry.data[CONF_USERNAME]}_".join(
- entity_entry.unique_id.split(f"{DOMAIN}_")
- )
-
- LOGGER.debug(
- "Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
- entity_entry.entity_id,
- entity_entry.unique_id,
- new_unique_id,
- )
-
- return {"new_unique_id": new_unique_id}
-
- await async_migrate_entries(hass, entry.entry_id, async_migrate_callback)
-
# Tile's API uses cookies to identify a consumer; in order to allow for multiple
# instances of this config entry, we use a new session each time:
websession = aiohttp_client.async_create_clientsession(hass)
@@ -82,47 +40,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except TileError as err:
raise ConfigEntryNotReady("Error during integration setup") from err
- async def async_update_tile(tile: Tile) -> None:
- """Update the Tile."""
- try:
- await tile.async_update()
- except InvalidAuthError as err:
- raise ConfigEntryAuthFailed("Invalid credentials") from err
- except SessionExpiredError:
- LOGGER.debug("Tile session expired; creating a new one")
- await client.async_init()
- except TileError as err:
- raise UpdateFailed(f"Error while retrieving data: {err}") from err
-
- coordinators: dict[str, DataUpdateCoordinator[None]] = {}
+ coordinators: dict[str, TileCoordinator] = {}
coordinator_init_tasks = []
for tile_uuid, tile in tiles.items():
- coordinator = coordinators[tile_uuid] = DataUpdateCoordinator(
- hass,
- LOGGER,
- config_entry=entry,
- name=tile.name,
- update_interval=DEFAULT_UPDATE_INTERVAL,
- update_method=partial(async_update_tile, tile),
+ coordinator = coordinators[tile_uuid] = TileCoordinator(
+ hass, entry, client, tile
)
coordinator_init_tasks.append(coordinator.async_refresh())
await gather_with_limited_concurrency(
DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks
)
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = TileData(coordinators=coordinators, tiles=tiles)
+ entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TileConfigEntry) -> bool:
"""Unload a Tile config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/tile/binary_sensor.py b/homeassistant/components/tile/binary_sensor.py
new file mode 100644
index 00000000000..1719c793c0e
--- /dev/null
+++ b/homeassistant/components/tile/binary_sensor.py
@@ -0,0 +1,69 @@
+"""Support for Tile binary sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from pytile.tile import Tile
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import TileConfigEntry, TileCoordinator
+from .entity import TileEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class TileBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes Tile binary sensor entity."""
+
+ is_on_fn: Callable[[Tile], bool]
+
+
+ENTITIES: tuple[TileBinarySensorEntityDescription, ...] = (
+ TileBinarySensorEntityDescription(
+ key="lost",
+ translation_key="lost",
+ is_on_fn=lambda tile: tile.lost,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up Tile binary sensors."""
+
+ async_add_entities(
+ TileBinarySensor(coordinator, entity_description)
+ for entity_description in ENTITIES
+ for coordinator in entry.runtime_data.values()
+ )
+
+
+class TileBinarySensor(TileEntity, BinarySensorEntity):
+ """Representation of a Tile binary sensor."""
+
+ entity_description: TileBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: TileCoordinator,
+ description: TileBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = (
+ f"{coordinator.username}_{self._tile.uuid}_{description.key}"
+ )
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if the binary sensor is on."""
+ return self.entity_description.is_on_fn(self._tile)
diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py
index 53425958341..2ff7c0ca9ed 100644
--- a/homeassistant/components/tile/config_flow.py
+++ b/homeassistant/components/tile/config_flow.py
@@ -71,10 +71,6 @@ class TileFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=self._username, data=data)
- 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_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
diff --git a/homeassistant/components/tile/coordinator.py b/homeassistant/components/tile/coordinator.py
new file mode 100644
index 00000000000..9a554c1e3ae
--- /dev/null
+++ b/homeassistant/components/tile/coordinator.py
@@ -0,0 +1,50 @@
+"""Update coordinator for Tile."""
+
+from datetime import timedelta
+
+from pytile.api import API
+from pytile.errors import InvalidAuthError, SessionExpiredError, TileError
+from pytile.tile import Tile
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import LOGGER
+
+type TileConfigEntry = ConfigEntry[dict[str, TileCoordinator]]
+
+
+class TileCoordinator(DataUpdateCoordinator[None]):
+ """Define an object to coordinate Tile data retrieval."""
+
+ config_entry: TileConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, entry: TileConfigEntry, client: API, tile: Tile
+ ) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=tile.name,
+ config_entry=entry,
+ update_interval=timedelta(minutes=2),
+ )
+ self.tile = tile
+ self.client = client
+ self.username = entry.data[CONF_USERNAME]
+
+ async def _async_update_data(self) -> None:
+ """Update data via library."""
+ try:
+ await self.tile.async_update()
+ except InvalidAuthError as err:
+ raise ConfigEntryAuthFailed("Invalid credentials") from err
+ except SessionExpiredError:
+ LOGGER.debug("Tile session expired; creating a new one")
+ await self.client.async_init()
+ except TileError as err:
+ raise UpdateFailed(f"Error while retrieving data: {err}") from err
diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py
index 71abbbef2c7..6a0aae1bdf9 100644
--- a/homeassistant/components/tile/device_tracker.py
+++ b/homeassistant/components/tile/device_tracker.py
@@ -4,23 +4,13 @@ from __future__ import annotations
import logging
-from pytile.tile import Tile
-
-from homeassistant.components.device_tracker import AsyncSeeCallback, TrackerEntity
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
from homeassistant.util.dt import as_utc
-from . import TileData
-from .const import DOMAIN
+from .coordinator import TileConfigEntry, TileCoordinator
+from .entity import TileEntity
_LOGGER = logging.getLogger(__name__)
@@ -36,72 +26,27 @@ ATTR_VOIP_STATE = "voip_state"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Tile device trackers."""
- data: TileData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- [
- TileDeviceTracker(entry, data.coordinators[tile_uuid], tile)
- for tile_uuid, tile in data.tiles.items()
- ]
+ TileDeviceTracker(coordinator) for coordinator in entry.runtime_data.values()
)
-async def async_setup_scanner(
- hass: HomeAssistant,
- config: ConfigType,
- async_see: AsyncSeeCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> bool:
- """Detect a legacy configuration and import it."""
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={
- CONF_USERNAME: config[CONF_USERNAME],
- CONF_PASSWORD: config[CONF_PASSWORD],
- },
- )
- )
-
- _LOGGER.debug(
- "Your Tile configuration has been imported into the UI; "
- "please remove it from configuration.yaml"
- )
-
- return True
-
-
-class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerEntity):
+class TileDeviceTracker(TileEntity, TrackerEntity):
"""Representation of a network infrastructure device."""
- _attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "tile"
- def __init__(
- self, entry: ConfigEntry, coordinator: DataUpdateCoordinator[None], tile: Tile
- ) -> None:
+ def __init__(self, coordinator: TileCoordinator) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_extra_state_attributes = {}
- self._attr_unique_id = f"{entry.data[CONF_USERNAME]}_{tile.uuid}"
- self._entry = entry
- self._tile = tile
-
- @property
- def available(self) -> bool:
- """Return if entity is available."""
- return super().available and not self._tile.dead
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return device info."""
- return DeviceInfo(identifiers={(DOMAIN, self._tile.uuid)}, name=self._tile.name)
+ self._attr_unique_id = f"{coordinator.username}_{self._tile.uuid}"
@callback
def _handle_coordinator_update(self) -> None:
diff --git a/homeassistant/components/tile/diagnostics.py b/homeassistant/components/tile/diagnostics.py
index 22991ef24c1..9db33b737c0 100644
--- a/homeassistant/components/tile/diagnostics.py
+++ b/homeassistant/components/tile/diagnostics.py
@@ -5,12 +5,10 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_UUID
from homeassistant.core import HomeAssistant
-from . import TileData
-from .const import DOMAIN
+from .coordinator import TileConfigEntry
CONF_ALTITUDE = "altitude"
@@ -23,11 +21,12 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: TileConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- data: TileData = hass.data[DOMAIN][entry.entry_id]
+ coordinators = entry.runtime_data.values()
return async_redact_data(
- {"tiles": [tile.as_dict() for tile in data.tiles.values()]}, TO_REDACT
+ {"tiles": [coordinator.tile.as_dict() for coordinator in coordinators]},
+ TO_REDACT,
)
diff --git a/homeassistant/components/tile/entity.py b/homeassistant/components/tile/entity.py
new file mode 100644
index 00000000000..21dd6df9cf8
--- /dev/null
+++ b/homeassistant/components/tile/entity.py
@@ -0,0 +1,30 @@
+"""Define a base Tile entity."""
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import TileCoordinator
+
+
+class TileEntity(CoordinatorEntity[TileCoordinator]):
+ """Define a base Tile entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: TileCoordinator) -> None:
+ """Initialize."""
+ super().__init__(coordinator)
+ self._tile = coordinator.tile
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._tile.uuid)},
+ name=self._tile.name,
+ manufacturer="Tile Inc.",
+ hw_version=self._tile.hardware_version,
+ sw_version=self._tile.firmware_version,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return super().available and not self._tile.dead
diff --git a/homeassistant/components/tile/icons.json b/homeassistant/components/tile/icons.json
index f6f38fe8cef..bac7cfcdcb0 100644
--- a/homeassistant/components/tile/icons.json
+++ b/homeassistant/components/tile/icons.json
@@ -1,5 +1,10 @@
{
"entity": {
+ "binary_sensor": {
+ "lost": {
+ "default": "mdi:map-marker-remove"
+ }
+ },
"device_tracker": {
"tile": {
"default": "mdi:view-grid"
diff --git a/homeassistant/components/tile/strings.json b/homeassistant/components/tile/strings.json
index 2d34d13c436..5146a5e9aff 100644
--- a/homeassistant/components/tile/strings.json
+++ b/homeassistant/components/tile/strings.json
@@ -33,5 +33,12 @@
}
}
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "lost": {
+ "name": "Lost"
+ }
+ }
}
}
diff --git a/homeassistant/components/tilt_ble/config_flow.py b/homeassistant/components/tilt_ble/config_flow.py
index 5c1f9721aae..b4a3235c60f 100644
--- a/homeassistant/components/tilt_ble/config_flow.py
+++ b/homeassistant/components/tilt_ble/config_flow.py
@@ -72,7 +72,7 @@ class TiltConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py
index 473472356d4..60e55c214fe 100644
--- a/homeassistant/components/time/__init__.py
+++ b/homeassistant/components/time/__init__.py
@@ -6,7 +6,7 @@ from datetime import time, timedelta
import logging
from typing import final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py
index 245d10bebba..1e86a1ba6c6 100644
--- a/homeassistant/components/time_date/sensor.py
+++ b/homeassistant/components/time_date/sensor.py
@@ -17,11 +17,11 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import OPTION_TYPES
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index 19b1de427ef..b0ade17b9c9 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -19,15 +19,14 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import collection
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py
index 126c3128f91..cbf3b073578 100644
--- a/homeassistant/components/tmb/sensor.py
+++ b/homeassistant/components/tmb/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py
index e4bc549a16b..937187c1c6f 100644
--- a/homeassistant/components/todo/__init__.py
+++ b/homeassistant/components/todo/__init__.py
@@ -8,7 +8,7 @@ import datetime
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components import frontend, websocket_api
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
index 62f9fafc02a..8c61394d300 100644
--- a/homeassistant/components/todoist/calendar.py
+++ b/homeassistant/components/todoist/calendar.py
@@ -22,8 +22,8 @@ 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 ServiceValidationError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -541,9 +541,8 @@ class TodoistProjectData:
return None
# All task Labels (optional parameter).
- task[LABELS] = [
- label.name for label in self._labels if label.name in data.labels
- ]
+ labels = data.labels or []
+ task[LABELS] = [label.name for label in self._labels if label.name in labels]
if self._label_whitelist and (
not any(label in task[LABELS] for label in self._label_whitelist)
):
diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json
index 72d76108353..791f5642aad 100644
--- a/homeassistant/components/todoist/manifest.json
+++ b/homeassistant/components/todoist/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/todoist",
"iot_class": "cloud_polling",
"loggers": ["todoist"],
- "requirements": ["todoist-api-python==2.1.2"]
+ "requirements": ["todoist-api-python==2.1.7"]
}
diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py
index d5d7e33a5e0..fed4ff332fc 100644
--- a/homeassistant/components/tolo/config_flow.py
+++ b/homeassistant/components/tolo/config_flow.py
@@ -8,10 +8,10 @@ from typing import Any
from tololib import ToloClient, ToloCommunicationError
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DEFAULT_NAME, DOMAIN
@@ -61,7 +61,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json
index 14125a857f6..613fc810683 100644
--- a/homeassistant/components/tolo/manifest.json
+++ b/homeassistant/components/tolo/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/tolo",
"iot_class": "local_polling",
"loggers": ["tololib"],
- "requirements": ["tololib==1.1.0"]
+ "requirements": ["tololib==1.2.2"]
}
diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py
index dfa8d2bd4e1..2cef5eea0cf 100644
--- a/homeassistant/components/tomato/device_tracker.py
+++ b/homeassistant/components/tomato/device_tracker.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
CONF_HTTP_ID = "http_id"
diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py
index 543046fac1c..8d4183e2961 100644
--- a/homeassistant/components/torque/sensor.py
+++ b/homeassistant/components/torque/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_EMAIL, CONF_NAME, DEGREE
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py
index 48ba78acc92..021d1c7b886 100644
--- a/homeassistant/components/totalconnect/alarm_control_panel.py
+++ b/homeassistant/components/totalconnect/alarm_control_panel.py
@@ -73,7 +73,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity):
) -> None:
"""Initialize the TotalConnect status."""
super().__init__(coordinator, location)
- self._partition_id = partition_id
+ self._partition_id = int(partition_id)
self._partition = self._location.partitions[partition_id]
"""
@@ -81,7 +81,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity):
for most users with new support for partitions.
Add _# for partition 2 and beyond.
"""
- if partition_id == 1:
+ if int(partition_id) == 1:
self._attr_name = None
self._attr_unique_id = str(location.location_id)
else:
diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json
index 33306a7adba..6aff1ea392b 100644
--- a/homeassistant/components/totalconnect/manifest.json
+++ b/homeassistant/components/totalconnect/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
"iot_class": "cloud_polling",
"loggers": ["total_connect_client"],
- "requirements": ["total-connect-client==2024.12"]
+ "requirements": ["total-connect-client==2025.1.4"]
}
diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml
index fb0f1e5098a..2ec54250b72 100644
--- a/homeassistant/components/totalconnect/quality_scale.yaml
+++ b/homeassistant/components/totalconnect/quality_scale.yaml
@@ -1,11 +1,11 @@
rules:
# Bronze
- config-flow: todo
+ config-flow: done
test-before-configure: done
unique-config-entry: done
config-flow-test-coverage: todo
runtime-data: done
- test-before-setup: todo
+ test-before-setup: done
appropriate-polling: done
entity-unique-id: done
has-entity-name: done
@@ -15,7 +15,7 @@ rules:
common-modules: done
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
docs-actions: done
brands: done
@@ -47,13 +47,11 @@ rules:
discovery-update-info: todo
repair-issues: todo
docs-use-cases: done
-
- # stopped here....
- docs-supported-devices: todo
- docs-supported-functions: todo
- docs-data-update: todo
- docs-known-limitations: todo
- docs-troubleshooting: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: done
docs-examples: done
# Platinum
diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json
index 004056ef9ac..daf720084a5 100644
--- a/homeassistant/components/totalconnect/strings.json
+++ b/homeassistant/components/totalconnect/strings.json
@@ -2,21 +2,36 @@
"config": {
"step": {
"user": {
+ "title": "Total Connect 2.0 Account Credentials",
+ "description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The Total Connect username",
+ "password": "The Total Connect password"
}
},
"locations": {
"title": "Location Usercodes",
"description": "Enter the usercode for this user at location {location_id}",
"data": {
- "usercode": "Usercode"
+ "usercodes": "Usercode"
+ },
+ "data_description": {
+ "usercodes": "The usercode is usually a 4 digit number"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "Total Connect needs to re-authenticate your account"
+ "description": "Total Connect needs to re-authenticate your account",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::totalconnect::config::step::user::data_description::password%]"
+ }
}
},
"error": {
@@ -36,6 +51,10 @@
"data": {
"auto_bypass_low_battery": "Auto bypass low battery",
"code_required": "Require user to enter code for alarm actions"
+ },
+ "data_description": {
+ "auto_bypass_low_battery": "If enabled, Total Connect zones will immediately be bypassed when they report low battery. This option helps because zones tend to report low battery in the middle of the night. The downside of this option is that when the alarm system is armed, the bypassed zone will not be monitored.",
+ "code_required": "If enabled, you must enter the user code to arm or disarm the alarm"
}
}
}
diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py
index e9d27341cb7..f7eec7c54f9 100644
--- a/homeassistant/components/touchline/climate.py
+++ b/homeassistant/components/touchline/climate.py
@@ -15,7 +15,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py
index e2a2f99517f..31bdcc5481c 100644
--- a/homeassistant/components/tplink/__init__.py
+++ b/homeassistant/components/tplink/__init__.py
@@ -6,7 +6,7 @@ import asyncio
from collections.abc import Iterable
from datetime import timedelta
import logging
-from typing import Any
+from typing import Any, cast
from aiohttp import ClientSession
from kasa import (
@@ -18,11 +18,9 @@ from kasa import (
KasaException,
)
from kasa.httpclient import get_cookie_jar
-from kasa.iot import IotStrip
from homeassistant import config_entries
from homeassistant.components import network
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ALIAS,
CONF_AUTHENTICATION,
@@ -59,10 +57,7 @@ from .const import (
DOMAIN,
PLATFORMS,
)
-from .coordinator import TPLinkDataUpdateCoordinator
-from .models import TPLinkData
-
-type TPLinkConfigEntry = ConfigEntry[TPLinkData]
+from .coordinator import TPLinkConfigEntry, TPLinkData, TPLinkDataUpdateCoordinator
DISCOVERY_INTERVAL = timedelta(minutes=15)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -178,9 +173,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
if not credentials and entry_credentials_hash:
data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH}
hass.config_entries.async_update_entry(entry, data=data)
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="device_authentication",
+ translation_placeholders={
+ "func": "connect",
+ "exc": str(ex),
+ },
+ ) from ex
except KasaException as ex:
- raise ConfigEntryNotReady from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_error",
+ translation_placeholders={
+ "func": "connect",
+ "exc": str(ex),
+ },
+ ) from ex
device_credentials_hash = device.credentials_hash
@@ -212,21 +221,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
# wait for the next discovery to find the device at its new address
# and update the config entry so we do not mix up devices.
raise ConfigEntryNotReady(
- f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}"
+ translation_domain=DOMAIN,
+ translation_key="unexpected_device",
+ translation_placeholders={
+ "host": host,
+ # all entries have a unique id
+ "expected": cast(str, entry.unique_id),
+ "found": found_mac,
+ },
)
- parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5))
- child_coordinators: list[TPLinkDataUpdateCoordinator] = []
-
- # The iot HS300 allows a limited number of concurrent requests and fetching the
- # emeter information requires separate ones so create child coordinators here.
- if isinstance(device, IotStrip):
- child_coordinators = [
- # The child coordinators only update energy data so we can
- # set a longer update interval to avoid flooding the device
- TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60))
- for child in device.children
- ]
+ parent_coordinator = TPLinkDataUpdateCoordinator(
+ hass, device, timedelta(seconds=5), entry
+ )
camera_creds: Credentials | None = None
if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS):
@@ -235,9 +242,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
)
live_view = entry.data.get(CONF_LIVE_VIEW)
- entry.runtime_data = TPLinkData(
- parent_coordinator, child_coordinators, camera_creds, live_view
- )
+ entry.runtime_data = TPLinkData(parent_coordinator, camera_creds, live_view)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -263,7 +268,7 @@ def legacy_device_id(device: Device) -> str:
return device_id.split("_")[1]
-def get_device_name(device: Device, parent: Device | None = None) -> str:
+def get_device_name(device: Device, parent: Device | None = None) -> str | None:
"""Get a name for the device. alias can be none on some devices."""
if device.alias:
return device.alias
@@ -278,7 +283,7 @@ def get_device_name(device: Device, parent: Device | None = None) -> str:
]
suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else ""
return f"{device.device_type.value.capitalize()}{suffix}"
- return f"Unnamed {device.model}"
+ return None
async def get_credentials(hass: HomeAssistant) -> Credentials | None:
@@ -325,7 +330,9 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None
)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: TPLinkConfigEntry
+) -> bool:
"""Migrate old entry."""
entry_version = config_entry.version
entry_minor_version = config_entry.minor_version
diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py
index f3a7e7a7ce7..6986765b110 100644
--- a/homeassistant/components/tplink/binary_sensor.py
+++ b/homeassistant/components/tplink/binary_sensor.py
@@ -8,6 +8,7 @@ from typing import Final, cast
from kasa import Feature
from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
@@ -23,14 +24,21 @@ from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescripti
class TPLinkBinarySensorEntityDescription(
BinarySensorEntityDescription, TPLinkFeatureEntityDescription
):
- """Base class for a TPLink feature based sensor entity description."""
+ """Base class for a TPLink feature based binary sensor entity description."""
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
BINARY_SENSOR_DESCRIPTIONS: Final = (
TPLinkBinarySensorEntityDescription(
key="overheated",
device_class=BinarySensorDeviceClass.PROBLEM,
),
+ TPLinkBinarySensorEntityDescription(
+ key="overloaded",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ ),
TPLinkBinarySensorEntityDescription(
key="battery_low",
device_class=BinarySensorDeviceClass.BATTERY,
@@ -39,11 +47,6 @@ BINARY_SENSOR_DESCRIPTIONS: Final = (
key="cloud_connection",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
- # To be replaced & disabled per default by the upcoming update platform.
- TPLinkBinarySensorEntityDescription(
- key="update_available",
- device_class=BinarySensorDeviceClass.UPDATE,
- ),
TPLinkBinarySensorEntityDescription(
key="temperature_warning",
),
@@ -75,19 +78,28 @@ async def async_setup_entry(
"""Set up sensors."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
- children_coordinators = data.children_coordinators
device = parent_coordinator.device
- entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
- hass=hass,
- device=device,
- coordinator=parent_coordinator,
- feature_type=Feature.Type.BinarySensor,
- entity_class=TPLinkBinarySensorEntity,
- descriptions=BINARYSENSOR_DESCRIPTIONS_MAP,
- child_coordinators=children_coordinators,
- )
- async_add_entities(entities)
+ known_child_device_ids: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ feature_type=Feature.Type.BinarySensor,
+ entity_class=TPLinkBinarySensorEntity,
+ descriptions=BINARYSENSOR_DESCRIPTIONS_MAP,
+ platform_domain=BINARY_SENSOR_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity):
diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py
index 753efcf89f4..4279a233d21 100644
--- a/homeassistant/components/tplink/button.py
+++ b/homeassistant/components/tplink/button.py
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry
-from .deprecate import DeprecatedInfo, async_cleanup_deprecated
+from .deprecate import DeprecatedInfo
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@@ -29,6 +29,10 @@ class TPLinkButtonEntityDescription(
"""Base class for a TPLink feature based button entity description."""
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
BUTTON_DESCRIPTIONS: Final = [
TPLinkButtonEntityDescription(
key="test_alarm",
@@ -66,6 +70,23 @@ BUTTON_DESCRIPTIONS: Final = [
key="tilt_down",
available_fn=lambda dev: dev.is_on,
),
+ TPLinkButtonEntityDescription(key="pair"),
+ TPLinkButtonEntityDescription(key="unpair"),
+ TPLinkButtonEntityDescription(
+ key="main_brush_reset",
+ ),
+ TPLinkButtonEntityDescription(
+ key="side_brush_reset",
+ ),
+ TPLinkButtonEntityDescription(
+ key="sensor_reset",
+ ),
+ TPLinkButtonEntityDescription(
+ key="filter_reset",
+ ),
+ TPLinkButtonEntityDescription(
+ key="charging_contacts_reset",
+ ),
]
BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS}
@@ -79,20 +100,27 @@ async def async_setup_entry(
"""Set up buttons."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
- children_coordinators = data.children_coordinators
device = parent_coordinator.device
+ known_child_device_ids: set[str] = set()
+ first_check = True
- entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
- hass=hass,
- device=device,
- coordinator=parent_coordinator,
- feature_type=Feature.Type.Action,
- entity_class=TPLinkButtonEntity,
- descriptions=BUTTON_DESCRIPTIONS_MAP,
- child_coordinators=children_coordinators,
- )
- async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities)
- async_add_entities(entities)
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ feature_type=Feature.Type.Action,
+ entity_class=TPLinkButtonEntity,
+ descriptions=BUTTON_DESCRIPTIONS_MAP,
+ platform_domain=BUTTON_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity):
diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py
index 4a6859a8414..b0f1f1a62c1 100644
--- a/homeassistant/components/tplink/camera.py
+++ b/homeassistant/components/tplink/camera.py
@@ -7,11 +7,11 @@ import time
from aiohttp import web
from haffmpeg.camera import CameraMjpeg
-from kasa import Credentials, Device, Module, StreamResolution
-from kasa.smartcam.modules import Camera as CameraModule
+from kasa import Device, Module, StreamResolution
from homeassistant.components import ffmpeg, stream
from homeassistant.components.camera import (
+ DOMAIN as CAMERA_DOMAIN,
Camera,
CameraEntityDescription,
CameraEntityFeature,
@@ -21,13 +21,17 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import TPLinkConfigEntry, legacy_device_id
+from . import TPLinkConfigEntry
from .const import CONF_CAMERA_CREDENTIALS
from .coordinator import TPLinkDataUpdateCoordinator
-from .entity import CoordinatedTPLinkEntity, TPLinkModuleEntityDescription
+from .entity import CoordinatedTPLinkModuleEntity, TPLinkModuleEntityDescription
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class TPLinkCameraEntityDescription(
@@ -41,6 +45,13 @@ CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = (
key="live_view",
translation_key="live_view",
available_fn=lambda dev: dev.is_on,
+ exists_fn=lambda dev, entry: (
+ (rtd := entry.runtime_data) is not None
+ and rtd.live_view is True
+ and (cam_creds := rtd.camera_credentials) is not None
+ and (cm := dev.modules.get(Module.Camera)) is not None
+ and cm.stream_rtsp_url(cam_creds) is not None
+ ),
),
)
@@ -54,26 +65,29 @@ async def async_setup_entry(
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
- camera_credentials = data.camera_credentials
- live_view = data.live_view
- ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
- async_add_entities(
- TPLinkCameraEntity(
- device,
- parent_coordinator,
- description,
- camera_module=camera_module,
- parent=None,
- ffmpeg_manager=ffmpeg_manager,
- camera_credentials=camera_credentials,
+ known_child_device_ids: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkCameraEntity,
+ descriptions=CAMERA_DESCRIPTIONS,
+ platform_domain=CAMERA_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
)
- for description in CAMERA_DESCRIPTIONS
- if (camera_module := device.modules.get(Module.Camera)) and live_view
- )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
-class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera):
+class TPLinkCameraEntity(CoordinatedTPLinkModuleEntity, Camera):
"""Representation of a TPLink camera."""
IMAGE_INTERVAL = 5 * 60
@@ -82,36 +96,38 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera):
entity_description: TPLinkCameraEntityDescription
+ _ffmpeg_manager: ffmpeg.FFmpegManager
+
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
description: TPLinkCameraEntityDescription,
*,
- camera_module: CameraModule,
parent: Device | None = None,
- ffmpeg_manager: ffmpeg.FFmpegManager,
- camera_credentials: Credentials | None,
) -> None:
"""Initialize a TPlink camera."""
- self.entity_description = description
- self._camera_module = camera_module
- self._video_url = camera_module.stream_rtsp_url(
- camera_credentials, stream_resolution=StreamResolution.SD
+ super().__init__(device, coordinator, description=description, parent=parent)
+ Camera.__init__(self)
+
+ self._camera_module = device.modules[Module.Camera]
+ self._camera_credentials = (
+ coordinator.config_entry.runtime_data.camera_credentials
+ )
+ self._video_url = self._camera_module.stream_rtsp_url(
+ self._camera_credentials, stream_resolution=StreamResolution.SD
)
self._image: bytes | None = None
- super().__init__(device, coordinator, parent=parent)
- Camera.__init__(self)
- self._ffmpeg_manager = ffmpeg_manager
self._image_lock = asyncio.Lock()
self._last_update: float = 0
- self._camera_credentials = camera_credentials
self._can_stream = True
self._http_mpeg_stream_running = False
- def _get_unique_id(self) -> str:
- """Return unique ID for the entity."""
- return f"{legacy_device_id(self._device)}-{self.entity_description.key}"
+ async def async_added_to_hass(self) -> None:
+ """Call update attributes after the device is added to the platform."""
+ await super().async_added_to_hass()
+
+ self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass)
@callback
def _async_update_attrs(self) -> bool:
@@ -130,7 +146,7 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera):
try:
await stream.async_check_stream_client_error(self.hass, video_url)
except stream.StreamOpenClientError as ex:
- if ex.stream_client_error is stream.StreamClientError.Unauthorized:
+ if ex.error_code is stream.StreamClientError.Unauthorized:
_LOGGER.debug(
"Camera stream failed authentication for %s",
self._device.host,
diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py
index f53a0d093ac..7204c2a7665 100644
--- a/homeassistant/components/tplink/climate.py
+++ b/homeassistant/components/tplink/climate.py
@@ -2,40 +2,72 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
import logging
from typing import Any, cast
-from kasa import Device, DeviceType
+from kasa import Device, Module
from kasa.smart.modules.temperaturecontrol import ThermostatState
from homeassistant.components.climate import (
ATTR_TEMPERATURE,
+ DOMAIN as CLIMATE_DOMAIN,
ClimateEntity,
+ ClimateEntityDescription,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
-from homeassistant.const import PRECISION_TENTHS
+from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import TPLinkConfigEntry
-from .const import UNIT_MAPPING
+from . import TPLinkConfigEntry, legacy_device_id
+from .const import DOMAIN, UNIT_MAPPING
from .coordinator import TPLinkDataUpdateCoordinator
-from .entity import CoordinatedTPLinkEntity, async_refresh_after
+from .entity import (
+ CoordinatedTPLinkModuleEntity,
+ TPLinkModuleEntityDescription,
+ async_refresh_after,
+)
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
# Upstream state to HVACAction
STATE_TO_ACTION = {
ThermostatState.Idle: HVACAction.IDLE,
ThermostatState.Heating: HVACAction.HEATING,
ThermostatState.Off: HVACAction.OFF,
+ ThermostatState.Calibrating: HVACAction.IDLE,
}
_LOGGER = logging.getLogger(__name__)
+@dataclass(frozen=True, kw_only=True)
+class TPLinkClimateEntityDescription(
+ ClimateEntityDescription, TPLinkModuleEntityDescription
+):
+ """Base class for climate entity description."""
+
+ unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = (
+ lambda device, desc: f"{legacy_device_id(device)}_{desc.key}"
+ )
+
+
+CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = (
+ TPLinkClimateEntityDescription(
+ key="climate",
+ exists_fn=lambda dev, _: Module.Thermostat in dev.modules,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
@@ -46,15 +78,28 @@ async def async_setup_entry(
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
- # As there are no standalone thermostats, we just iterate over the children.
- async_add_entities(
- TPLinkClimateEntity(child, parent_coordinator, parent=device)
- for child in device.children
- if child.device_type is DeviceType.Thermostat
- )
+ known_child_device_ids: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkClimateEntity,
+ descriptions=CLIMATE_DESCRIPTIONS,
+ platform_domain=CLIMATE_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
-class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
+class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
"""Representation of a TPLink thermostat."""
_attr_name = None
@@ -66,78 +111,95 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_precision = PRECISION_TENTHS
+ entity_description: TPLinkClimateEntityDescription
+
# This disables the warning for async_turn_{on,off}, can be removed later.
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkClimateEntityDescription,
*,
parent: Device,
) -> None:
"""Initialize the climate entity."""
- self._state_feature = device.features["state"]
- self._mode_feature = device.features["thermostat_mode"]
- self._temp_feature = device.features["temperature"]
- self._target_feature = device.features["target_temperature"]
+ super().__init__(device, coordinator, description, parent=parent)
+ self._thermostat_module = device.modules[Module.Thermostat]
- self._attr_min_temp = self._target_feature.minimum_value
- self._attr_max_temp = self._target_feature.maximum_value
- self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)]
+ if target_feature := self._thermostat_module.get_feature("target_temperature"):
+ self._attr_min_temp = target_feature.minimum_value
+ self._attr_max_temp = target_feature.maximum_value
+ else:
+ _LOGGER.error(
+ "Unable to get min/max target temperature for %s, using defaults",
+ device.host,
+ )
- super().__init__(device, coordinator, parent=parent)
+ if temperature_feature := self._thermostat_module.get_feature("temperature"):
+ self._attr_temperature_unit = UNIT_MAPPING[
+ cast(str, temperature_feature.unit)
+ ]
+ else:
+ _LOGGER.error(
+ "Unable to get correct temperature unit for %s, defaulting to celsius",
+ device.host,
+ )
+ self._attr_temperature_unit = UnitOfTemperature.CELSIUS
@async_refresh_after
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set target temperature."""
- await self._target_feature.set_value(int(kwargs[ATTR_TEMPERATURE]))
+ await self._thermostat_module.set_target_temperature(
+ float(kwargs[ATTR_TEMPERATURE])
+ )
@async_refresh_after
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode (heat/off)."""
if hvac_mode is HVACMode.HEAT:
- await self._state_feature.set_value(True)
+ await self._thermostat_module.set_state(True)
elif hvac_mode is HVACMode.OFF:
- await self._state_feature.set_value(False)
+ await self._thermostat_module.set_state(False)
else:
- raise ServiceValidationError(f"Tried to set unsupported mode: {hvac_mode}")
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_mode",
+ translation_placeholders={
+ "mode": hvac_mode,
+ },
+ )
@async_refresh_after
async def async_turn_on(self) -> None:
"""Turn heating on."""
- await self._state_feature.set_value(True)
+ await self._thermostat_module.set_state(True)
@async_refresh_after
async def async_turn_off(self) -> None:
"""Turn heating off."""
- await self._state_feature.set_value(False)
+ await self._thermostat_module.set_state(False)
@callback
def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
- self._attr_current_temperature = cast(float | None, self._temp_feature.value)
- self._attr_target_temperature = cast(float | None, self._target_feature.value)
+ self._attr_current_temperature = self._thermostat_module.temperature
+ self._attr_target_temperature = self._thermostat_module.target_temperature
self._attr_hvac_mode = (
- HVACMode.HEAT if self._state_feature.value else HVACMode.OFF
+ HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF
)
if (
- self._mode_feature.value not in STATE_TO_ACTION
+ self._thermostat_module.mode not in STATE_TO_ACTION
and self._attr_hvac_action is not HVACAction.OFF
):
_LOGGER.warning(
"Unknown thermostat state, defaulting to OFF: %s",
- self._mode_feature.value,
+ self._thermostat_module.mode,
)
self._attr_hvac_action = HVACAction.OFF
return True
- self._attr_hvac_action = STATE_TO_ACTION[
- cast(ThermostatState, self._mode_feature.value)
- ]
+ self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]
return True
-
- def _get_unique_id(self) -> str:
- """Return unique id."""
- return f"{self._device.device_id}_climate"
diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py
index db6f9a58ba5..9ca2fe80cf9 100644
--- a/homeassistant/components/tplink/config_flow.py
+++ b/homeassistant/components/tplink/config_flow.py
@@ -18,7 +18,7 @@ from kasa import (
)
import voluptuous as vol
-from homeassistant.components import dhcp, ffmpeg, stream
+from homeassistant.components import ffmpeg, stream
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
@@ -40,6 +40,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType
from . import (
@@ -93,7 +94,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device: Device | None = None
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
return await self._async_handle_discovery(
@@ -468,7 +469,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await stream.async_check_stream_client_error(self.hass, rtsp_url)
except stream.StreamOpenClientError as ex:
- if ex.stream_client_error is stream.StreamClientError.Unauthorized:
+ if ex.error_code is stream.StreamClientError.Unauthorized:
errors["base"] = "invalid_camera_auth"
else:
_LOGGER.debug(
diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py
index 61c1bf1cb9b..2df7101791a 100644
--- a/homeassistant/components/tplink/const.py
+++ b/homeassistant/components/tplink/const.py
@@ -4,7 +4,9 @@ from __future__ import annotations
from typing import Final
-from homeassistant.const import Platform, UnitOfTemperature
+from kasa.smart.modules.clean import AreaUnit
+
+from homeassistant.const import Platform, UnitOfArea, UnitOfTemperature
DOMAIN = "tplink"
@@ -41,9 +43,12 @@ PLATFORMS: Final = [
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
+ Platform.VACUUM,
]
UNIT_MAPPING = {
"celsius": UnitOfTemperature.CELSIUS,
"fahrenheit": UnitOfTemperature.FAHRENHEIT,
+ AreaUnit.Sqm: UnitOfArea.SQUARE_METERS,
+ AreaUnit.Sqft: UnitOfArea.SQUARE_FEET,
}
diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py
index 1c362d33746..d1b4694779d 100644
--- a/homeassistant/components/tplink/coordinator.py
+++ b/homeassistant/components/tplink/coordinator.py
@@ -2,38 +2,63 @@
from __future__ import annotations
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from kasa import AuthenticationError, Device, KasaException
+from kasa import AuthenticationError, Credentials, Device, KasaException
+from kasa.iot import IotStrip
-from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from .const import DOMAIN
+
_LOGGER = logging.getLogger(__name__)
+
+@dataclass(slots=True)
+class TPLinkData:
+ """Data for the tplink integration."""
+
+ parent_coordinator: TPLinkDataUpdateCoordinator
+ camera_credentials: Credentials | None
+ live_view: bool | None
+
+
+type TPLinkConfigEntry = ConfigEntry[TPLinkData]
+
REQUEST_REFRESH_DELAY = 0.35
class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator to gather data for a specific TPLink device."""
- config_entry: config_entries.ConfigEntry
+ config_entry: TPLinkConfigEntry
def __init__(
self,
hass: HomeAssistant,
device: Device,
update_interval: timedelta,
+ config_entry: TPLinkConfigEntry,
) -> None:
"""Initialize DataUpdateCoordinator to gather data for specific SmartPlug."""
self.device = device
+
+ # The iot HS300 allows a limited number of concurrent requests and
+ # fetching the emeter information requires separate ones, so child
+ # coordinators are created below in get_child_coordinator.
+ self._update_children = not isinstance(device, IotStrip)
+
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=device.host,
update_interval=update_interval,
# We don't want an immediate refresh since the device
@@ -42,12 +67,74 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
+ self._previous_child_device_ids = {child.device_id for child in device.children}
+ self.removed_child_device_ids: set[str] = set()
+ self._child_coordinators: dict[str, TPLinkDataUpdateCoordinator] = {}
async def _async_update_data(self) -> None:
"""Fetch all device and sensor data from api."""
try:
- await self.device.update(update_children=False)
+ await self.device.update(update_children=self._update_children)
except AuthenticationError as ex:
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="device_authentication",
+ translation_placeholders={
+ "func": "update",
+ "exc": str(ex),
+ },
+ ) from ex
except KasaException as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="device_error",
+ translation_placeholders={
+ "func": "update",
+ "exc": str(ex),
+ },
+ ) from ex
+
+ await self._process_child_devices()
+
+ async def _process_child_devices(self) -> None:
+ """Process child devices and remove stale devices."""
+ current_child_device_ids = {child.device_id for child in self.device.children}
+ if (
+ stale_device_ids := self._previous_child_device_ids
+ - current_child_device_ids
+ ):
+ device_registry = dr.async_get(self.hass)
+ for device_id in stale_device_ids:
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, device_id)}
+ )
+ if device:
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+ child_coordinator = self._child_coordinators.pop(device_id, None)
+ if child_coordinator:
+ await child_coordinator.async_shutdown()
+
+ self._previous_child_device_ids = current_child_device_ids
+ self.removed_child_device_ids = stale_device_ids
+
+ def get_child_coordinator(
+ self,
+ child: Device,
+ ) -> TPLinkDataUpdateCoordinator:
+ """Get separate child coordinator for a device or self if not needed."""
+ # The iot HS300 allows a limited number of concurrent requests and fetching the
+ # emeter information requires separate ones so create child coordinators here.
+ if isinstance(self.device, IotStrip):
+ if not (child_coordinator := self._child_coordinators.get(child.device_id)):
+ # The child coordinators only update energy data so we can
+ # set a longer update interval to avoid flooding the device
+ child_coordinator = TPLinkDataUpdateCoordinator(
+ self.hass, child, timedelta(seconds=60), self.config_entry
+ )
+ self._child_coordinators[child.device_id] = child_coordinator
+ return child_coordinator
+
+ return self
diff --git a/homeassistant/components/tplink/deprecate.py b/homeassistant/components/tplink/deprecate.py
index 738f3d24c38..86d4f66cdc0 100644
--- a/homeassistant/components/tplink/deprecate.py
+++ b/homeassistant/components/tplink/deprecate.py
@@ -6,16 +6,20 @@ from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
+from kasa import Device
+
from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from . import legacy_device_id
from .const import DOMAIN
if TYPE_CHECKING:
- from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
+ from .entity import CoordinatedTPLinkEntity, TPLinkEntityDescription
@dataclass(slots=True)
@@ -30,7 +34,7 @@ class DeprecatedInfo:
def async_check_create_deprecated(
hass: HomeAssistant,
unique_id: str,
- entity_description: TPLinkFeatureEntityDescription,
+ entity_description: TPLinkEntityDescription,
) -> bool:
"""Return true if the entity should be created based on the deprecated_info.
@@ -58,13 +62,21 @@ def async_check_create_deprecated(
return not entity_entry.disabled
-def async_cleanup_deprecated(
+def async_process_deprecated(
hass: HomeAssistant,
- platform: str,
+ platform_domain: str,
entry_id: str,
- entities: Sequence[CoordinatedTPLinkFeatureEntity],
+ entities: Sequence[CoordinatedTPLinkEntity],
+ device: Device,
) -> None:
- """Remove disabled deprecated entities or create issues if necessary."""
+ """Process deprecated entities for a device.
+
+ Create issues for deprececated entities that appear in automations.
+ Delete entities that are no longer provided by the integration either
+ because they have been removed at the end of the deprecation period, or
+ they are disabled by the user so the async_check_create_deprecated
+ returned false.
+ """
ent_reg = er.async_get(hass)
for entity in entities:
if not (deprecated_info := entity.entity_description.deprecated_info):
@@ -72,7 +84,7 @@ def async_cleanup_deprecated(
assert entity.unique_id
entity_id = ent_reg.async_get_entity_id(
- platform,
+ platform_domain,
DOMAIN,
entity.unique_id,
)
@@ -94,17 +106,27 @@ def async_cleanup_deprecated(
translation_placeholders={
"entity": entity_id,
"info": item,
- "platform": platform,
+ "platform": platform_domain,
"new_platform": deprecated_info.new_platform,
},
)
+ # The light platform does not currently support cleaning up disabled
+ # deprecated entities because it uses two entity classes so a completeness
+ # check is not possible. It also uses the mac address as device id in some
+ # instances instead of device_id.
+ if platform_domain == LIGHT_DOMAIN:
+ return
+
# Remove entities that are no longer provided and have been disabled.
+ device_id = legacy_device_id(device)
+
unique_ids = {entity.unique_id for entity in entities}
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry_id):
if (
- entity_entry.domain == platform
+ entity_entry.domain == platform_domain
and entity_entry.disabled
+ and entity_entry.unique_id.startswith(device_id)
and entity_entry.unique_id not in unique_ids
):
ent_reg.async_remove(entity_entry.entity_id)
diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py
index 935857e5db1..15c07655e69 100644
--- a/homeassistant/components/tplink/entity.py
+++ b/homeassistant/components/tplink/entity.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from collections.abc import Awaitable, Callable, Coroutine, Mapping
+from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping
from dataclasses import dataclass, replace
import logging
from typing import Any, Concatenate
@@ -35,8 +35,12 @@ from .const import (
DOMAIN,
PRIMARY_STATE_ID,
)
-from .coordinator import TPLinkDataUpdateCoordinator
-from .deprecate import DeprecatedInfo, async_check_create_deprecated
+from .coordinator import TPLinkConfigEntry, TPLinkDataUpdateCoordinator
+from .deprecate import (
+ DeprecatedInfo,
+ async_check_create_deprecated,
+ async_process_deprecated,
+)
_LOGGER = logging.getLogger(__name__)
@@ -55,6 +59,7 @@ DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = {
DeviceType.Dimmer,
DeviceType.Fan,
DeviceType.Thermostat,
+ DeviceType.Vacuum,
}
# Primary features to always include even when the device type has its own platform
@@ -85,7 +90,7 @@ LEGACY_KEY_MAPPING = {
@dataclass(frozen=True, kw_only=True)
-class TPLinkFeatureEntityDescription(EntityDescription):
+class TPLinkEntityDescription(EntityDescription):
"""Base class for a TPLink feature based entity description."""
deprecated_info: DeprecatedInfo | None = None
@@ -93,11 +98,21 @@ class TPLinkFeatureEntityDescription(EntityDescription):
@dataclass(frozen=True, kw_only=True)
-class TPLinkModuleEntityDescription(EntityDescription):
+class TPLinkFeatureEntityDescription(TPLinkEntityDescription):
+ """Base class for a TPLink feature based entity description."""
+
+
+@dataclass(frozen=True, kw_only=True)
+class TPLinkModuleEntityDescription(TPLinkEntityDescription):
"""Base class for a TPLink module based entity description."""
- deprecated_info: DeprecatedInfo | None = None
- available_fn: Callable[[Device], bool] = lambda _: True
+ exists_fn: Callable[[Device, TPLinkConfigEntry], bool]
+ unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = (
+ lambda device, desc: f"{legacy_device_id(device)}-{desc.key}"
+ )
+ entity_name_fn: (
+ Callable[[Device, TPLinkModuleEntityDescription], str | None] | None
+ ) = None
def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
@@ -147,21 +162,29 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB
_attr_has_entity_name = True
_device: Device
+ entity_description: TPLinkEntityDescription
+
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkEntityDescription,
*,
feature: Feature | None = None,
parent: Device | None = None,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
+ self.entity_description = description
self._device: Device = device
+ self._parent = parent
self._feature = feature
registry_device = device
device_name = get_device_name(device, parent=parent)
+ translation_key: str | None = None
+ translation_placeholders: Mapping[str, str] | None = None
+
if parent and parent.device_type is not Device.Type.Hub:
if not feature or feature.id == PRIMARY_STATE_ID:
# Entity will be added to parent if not a hub and no feature
@@ -169,6 +192,9 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB
# is the primary state
registry_device = parent
device_name = get_device_name(registry_device)
+ if not device_name:
+ translation_key = "unnamed_device"
+ translation_placeholders = {"model": parent.model}
else:
# Prefix the device name with the parent name unless it is a
# hub attached device. Sensible default for child devices like
@@ -177,17 +203,36 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB
# Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan
# and Dimmer Switch for both so should be distinguished by the
# parent name.
- device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}"
+ parent_device_name = get_device_name(parent)
+ child_device_name = get_device_name(device, parent=parent)
+ if parent_device_name:
+ device_name = f"{parent_device_name} {child_device_name}"
+ else:
+ device_name = None
+ translation_key = "unnamed_device"
+ translation_placeholders = {
+ "model": f"{parent.model} {child_device_name}"
+ }
+
+ if device_name is None and not translation_key:
+ translation_key = "unnamed_device"
+ translation_placeholders = {"model": device.model}
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(registry_device.device_id))},
manufacturer="TP-Link",
model=registry_device.model,
name=device_name,
+ translation_key=translation_key,
+ translation_placeholders=translation_placeholders,
sw_version=registry_device.hw_info["sw_ver"],
hw_version=registry_device.hw_info["hw_ver"],
)
+ # child device entities will link via_device unless they were created
+ # above on the parent. Otherwise the mac connections is set which or
+ # for wall switches like the ks240 will mean the child and parent devices
+ # are treated as one device.
if (
parent is not None
and parent != registry_device
@@ -201,11 +246,15 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB
self._attr_unique_id = self._get_unique_id()
- self._async_call_update_attrs()
-
def _get_unique_id(self) -> str:
"""Return unique ID for the entity."""
- return legacy_device_id(self._device)
+ raise NotImplementedError
+
+ async def async_added_to_hass(self) -> None:
+ """Call update attributes after the device is added to the platform."""
+ await super().async_added_to_hass()
+
+ self._async_call_update_attrs()
@abstractmethod
@callback
@@ -255,14 +304,19 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkFeatureEntityDescription,
*,
feature: Feature,
- description: TPLinkFeatureEntityDescription,
parent: Device | None = None,
) -> None:
"""Initialize the entity."""
- self.entity_description = description
- super().__init__(device, coordinator, parent=parent, feature=feature)
+ super().__init__(
+ device, coordinator, description, parent=parent, feature=feature
+ )
+
+ # Update the feature attributes so the registered entity contains
+ # values like unit_of_measurement and suggested_display_precision
+ self._async_call_update_attrs()
def _get_unique_id(self) -> str:
"""Return unique ID for the entity."""
@@ -320,6 +374,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
if descriptions and (desc := descriptions.get(feature.id)):
translation_key: str | None = feature.id
+
# HA logic is to name entities based on the following logic:
# _attr_name > translation.name > description.name
# > device_class (if base platform supports).
@@ -363,6 +418,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
feature_type: Feature.Type,
entity_class: type[_E],
descriptions: Mapping[str, _D],
+ platform_domain: str,
parent: Device | None = None,
) -> list[_E]:
"""Return a list of entities to add.
@@ -397,6 +453,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
desc,
)
]
+ async_process_deprecated(
+ hass, platform_domain, coordinator.config_entry.entry_id, entities, device
+ )
return entities
@classmethod
@@ -412,7 +471,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
feature_type: Feature.Type,
entity_class: type[_E],
descriptions: Mapping[str, _D],
- child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None,
+ platform_domain: str,
+ known_child_device_ids: set[str],
+ first_check: bool,
) -> list[_E]:
"""Create entities for device and its children.
@@ -420,36 +481,238 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
"""
entities: list[_E] = []
# Add parent entities before children so via_device id works.
- entities.extend(
- cls._entities_for_device(
+ # Only add the parent entities the first time
+ if first_check:
+ entities.extend(
+ cls._entities_for_device(
+ hass,
+ device,
+ coordinator=coordinator,
+ feature_type=feature_type,
+ entity_class=entity_class,
+ descriptions=descriptions,
+ platform_domain=platform_domain,
+ )
+ )
+
+ children = _get_new_children(
+ device, coordinator, known_child_device_ids, entity_class.__name__
+ )
+
+ if children:
+ _LOGGER.debug(
+ "Getting %s entities for %s child devices on device %s",
+ entity_class.__name__,
+ len(children),
+ device.host,
+ )
+
+ for child in children:
+ child_coordinator = coordinator.get_child_coordinator(child)
+
+ child_entities = cls._entities_for_device(
hass,
- device,
- coordinator=coordinator,
+ child,
+ coordinator=child_coordinator,
feature_type=feature_type,
entity_class=entity_class,
descriptions=descriptions,
+ platform_domain=platform_domain,
+ parent=device,
)
- )
- if device.children:
- _LOGGER.debug("Initializing device with %s children", len(device.children))
- for idx, child in enumerate(device.children):
- # HS300 does not like too many concurrent requests and its
- # emeter data requires a request for each socket, so we receive
- # separate coordinators.
- if child_coordinators:
- child_coordinator = child_coordinators[idx]
- else:
- child_coordinator = coordinator
- entities.extend(
- cls._entities_for_device(
- hass,
- child,
- coordinator=child_coordinator,
- feature_type=feature_type,
- entity_class=entity_class,
- descriptions=descriptions,
- parent=device,
- )
- )
+ _LOGGER.debug(
+ "Device %s, found %s child %s entities for child id %s",
+ device.host,
+ len(entities),
+ entity_class.__name__,
+ child.device_id,
+ )
+ entities.extend(child_entities)
return entities
+
+
+class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC):
+ """Common base class for all coordinated tplink module based entities."""
+
+ entity_description: TPLinkModuleEntityDescription
+
+ def __init__(
+ self,
+ device: Device,
+ coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkModuleEntityDescription,
+ *,
+ parent: Device | None = None,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(device, coordinator, description, parent=parent)
+
+ # Module based entities will usually be 1 per device so they will use
+ # the device name. If there are multiple module entities based entities
+ # the description should have a translation key.
+ # HA logic is to name entities based on the following logic:
+ # _attr_name > translation.name > description.name
+ if entity_name_fn := description.entity_name_fn:
+ self._attr_name = entity_name_fn(device, description)
+ elif not description.translation_key:
+ if parent is None or parent.device_type is Device.Type.Hub:
+ self._attr_name = None
+ else:
+ self._attr_name = get_device_name(device)
+
+ def _get_unique_id(self) -> str:
+ """Return unique ID for the entity."""
+ desc = self.entity_description
+ return desc.unique_id_fn(self._device, desc)
+
+ @classmethod
+ def _entities_for_device[
+ _E: CoordinatedTPLinkModuleEntity,
+ _D: TPLinkModuleEntityDescription,
+ ](
+ cls,
+ hass: HomeAssistant,
+ device: Device,
+ coordinator: TPLinkDataUpdateCoordinator,
+ *,
+ entity_class: type[_E],
+ descriptions: Iterable[_D],
+ platform_domain: str,
+ parent: Device | None = None,
+ ) -> list[_E]:
+ """Return a list of entities to add."""
+ entities: list[_E] = [
+ entity_class(
+ device,
+ coordinator,
+ description=description,
+ parent=parent,
+ )
+ for description in descriptions
+ if description.exists_fn(device, coordinator.config_entry)
+ and async_check_create_deprecated(
+ hass,
+ description.unique_id_fn(device, description),
+ description,
+ )
+ ]
+ async_process_deprecated(
+ hass, platform_domain, coordinator.config_entry.entry_id, entities, device
+ )
+ return entities
+
+ @classmethod
+ def entities_for_device_and_its_children[
+ _E: CoordinatedTPLinkModuleEntity,
+ _D: TPLinkModuleEntityDescription,
+ ](
+ cls,
+ hass: HomeAssistant,
+ device: Device,
+ coordinator: TPLinkDataUpdateCoordinator,
+ *,
+ entity_class: type[_E],
+ descriptions: Iterable[_D],
+ platform_domain: str,
+ known_child_device_ids: set[str],
+ first_check: bool,
+ ) -> list[_E]:
+ """Create entities for device and its children.
+
+ This is a helper that calls *_entities_for_device* for the device and its children.
+ """
+ entities: list[_E] = []
+
+ # Add parent entities before children so via_device id works.
+ # Only add the parent entities the first time
+ if first_check:
+ entities.extend(
+ cls._entities_for_device(
+ hass,
+ device,
+ coordinator=coordinator,
+ entity_class=entity_class,
+ descriptions=descriptions,
+ platform_domain=platform_domain,
+ )
+ )
+ has_parent_entities = bool(entities)
+
+ children = _get_new_children(
+ device, coordinator, known_child_device_ids, entity_class.__name__
+ )
+
+ if children:
+ _LOGGER.debug(
+ "Getting %s entities for %s child devices on device %s",
+ entity_class.__name__,
+ len(children),
+ device.host,
+ )
+ for child in children:
+ child_coordinator = coordinator.get_child_coordinator(child)
+
+ child_entities: list[_E] = cls._entities_for_device(
+ hass,
+ child,
+ coordinator=child_coordinator,
+ entity_class=entity_class,
+ descriptions=descriptions,
+ platform_domain=platform_domain,
+ parent=device,
+ )
+ _LOGGER.debug(
+ "Device %s, found %s child %s entities for child id %s",
+ device.host,
+ len(entities),
+ entity_class.__name__,
+ child.device_id,
+ )
+ entities.extend(child_entities)
+
+ if first_check and entities and not has_parent_entities:
+ # Get or create the parent device for via_device.
+ # This is a timing factor in case this platform is loaded before
+ # other platforms that will have entities on the parent. Eventually
+ # those other platforms will update the parent with full DeviceInfo
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=coordinator.config_entry.entry_id,
+ identifiers={(DOMAIN, device.device_id)},
+ )
+ return entities
+
+
+def _get_new_children(
+ device: Device,
+ coordinator: TPLinkDataUpdateCoordinator,
+ known_child_device_ids: set[str],
+ entity_class_name: str,
+) -> list[Device]:
+ """Get a list of children to check for entity creation."""
+ # Remove any device ids removed via the coordinator so they can be re-added
+ for removed_child_id in coordinator.removed_child_device_ids:
+ _LOGGER.debug(
+ "Removing %s from known %s child ids for device %s"
+ "as it has been removed by the coordinator",
+ removed_child_id,
+ entity_class_name,
+ device.host,
+ )
+ known_child_device_ids.discard(removed_child_id)
+
+ current_child_devices = {child.device_id: child for child in device.children}
+ current_child_device_ids = set(current_child_devices.keys())
+ new_child_device_ids = current_child_device_ids - known_child_device_ids
+ children = []
+
+ if new_child_device_ids:
+ children = [
+ child
+ for child_id, child in current_child_devices.items()
+ if child_id in new_child_device_ids
+ ]
+ known_child_device_ids.update(new_child_device_ids)
+ return children
+ return []
diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py
index a1e62e4ed69..1c31d84b778 100644
--- a/homeassistant/components/tplink/fan.py
+++ b/homeassistant/components/tplink/fan.py
@@ -1,13 +1,19 @@
"""Support for TPLink Fan devices."""
+from collections.abc import Callable
+from dataclasses import dataclass
import logging
import math
from typing import Any
from kasa import Device, Module
-from kasa.interfaces import Fan as FanInterface
-from homeassistant.components.fan import FanEntity, FanEntityFeature
+from homeassistant.components.fan import (
+ DOMAIN as FAN_DOMAIN,
+ FanEntity,
+ FanEntityDescription,
+ FanEntityFeature,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
@@ -16,13 +22,40 @@ from homeassistant.util.percentage import (
)
from homeassistant.util.scaling import int_states_in_range
-from . import TPLinkConfigEntry
+from . import TPLinkConfigEntry, legacy_device_id
from .coordinator import TPLinkDataUpdateCoordinator
-from .entity import CoordinatedTPLinkEntity, async_refresh_after
+from .entity import (
+ CoordinatedTPLinkModuleEntity,
+ TPLinkModuleEntityDescription,
+ async_refresh_after,
+)
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
+@dataclass(frozen=True, kw_only=True)
+class TPLinkFanEntityDescription(FanEntityDescription, TPLinkModuleEntityDescription):
+ """Base class for fan entity description."""
+
+ unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = (
+ lambda device, desc: legacy_device_id(device)
+ if desc.key == "fan"
+ else f"{legacy_device_id(device)}-{desc.key}"
+ )
+
+
+FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = (
+ TPLinkFanEntityDescription(
+ key="fan",
+ exists_fn=lambda dev, _: Module.Fan in dev.modules,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
@@ -32,30 +65,32 @@ async def async_setup_entry(
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
- entities: list[CoordinatedTPLinkEntity] = []
- if Module.Fan in device.modules:
- entities.append(
- TPLinkFanEntity(
- device, parent_coordinator, fan_module=device.modules[Module.Fan]
- )
+
+ known_child_device_ids: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkFanEntity,
+ descriptions=FAN_DESCRIPTIONS,
+ platform_domain=FAN_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
)
- entities.extend(
- TPLinkFanEntity(
- child,
- parent_coordinator,
- fan_module=child.modules[Module.Fan],
- parent=device,
- )
- for child in device.children
- if Module.Fan in child.modules
- )
- async_add_entities(entities)
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
SPEED_RANGE = (1, 4) # off is not included
-class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity):
+class TPLinkFanEntity(CoordinatedTPLinkModuleEntity, FanEntity):
"""Representation of a fan for a TPLink Fan device."""
_attr_speed_count = int_states_in_range(SPEED_RANGE)
@@ -65,19 +100,19 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity):
| FanEntityFeature.TURN_ON
)
+ entity_description: TPLinkFanEntityDescription
+
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
- fan_module: FanInterface,
+ description: TPLinkFanEntityDescription,
+ *,
parent: Device | None = None,
) -> None:
"""Initialize the fan."""
- self.fan_module = fan_module
- # If _attr_name is None the entity name will be the device name
- self._attr_name = None if parent is None else device.alias
-
- super().__init__(device, coordinator, parent=parent)
+ super().__init__(device, coordinator, description, parent=parent)
+ self.fan_module = device.modules[Module.Fan]
@async_refresh_after
async def async_turn_on(
diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json
index 9cc0326b59f..73bb40a8386 100644
--- a/homeassistant/components/tplink/icons.json
+++ b/homeassistant/components/tplink/icons.json
@@ -32,7 +32,20 @@
},
"tilt_down": {
"default": "mdi:chevron-down"
- }
+ },
+ "main_brush_reset": {
+ "default": "mdi:brush"
+ },
+ "side_brush_reset": {
+ "default": "mdi:brush"
+ },
+ "sensor_reset": {
+ "default": "mdi:eye-outline"
+ },
+ "filter_reset": {
+ "default": "mdi:air-filter"
+ },
+ "charging_contacts_reset": {}
},
"select": {
"light_preset": {
@@ -113,6 +126,9 @@
"state": {
"on": "mdi:baby-face"
}
+ },
+ "carpet_boost": {
+ "default": "mdi:rug"
}
},
"sensor": {
@@ -125,17 +141,40 @@
"signal_level": {
"default": "mdi:signal"
},
- "current_firmware_version": {
- "default": "mdi:information"
- },
- "available_firmware_version": {
- "default": "mdi:information-outline"
- },
"alarm_source": {
"default": "mdi:bell"
},
"water_alert_timestamp": {
"default": "mdi:clock-alert-outline"
+ },
+ "main_brush_remaining": {
+ "default": "mdi:brush"
+ },
+ "main_brush_used": {
+ "default": "mdi:brush"
+ },
+ "side_brush_remaining": {
+ "default": "mdi:brush"
+ },
+ "side_brush_used": {
+ "default": "mdi:brush"
+ },
+ "filter_remaining": {
+ "default": "mdi:air-filter"
+ },
+ "filter_used": {
+ "default": "mdi:air-filter"
+ },
+ "sensor_remaining": {
+ "default": "mdi:eye-outline"
+ },
+ "sensor_used": {
+ "default": "mdi:eye-outline"
+ },
+ "charging_contacts_remaining": {},
+ "charging_contacts_used": {},
+ "vacuum_error": {
+ "default": "mdi:alert-circle"
}
},
"number": {
@@ -151,14 +190,14 @@
"temperature_offset": {
"default": "mdi:contrast"
},
- "target_temperature": {
- "default": "mdi:thermometer"
- },
"pan_step": {
"default": "mdi:unfold-more-vertical"
},
"tilt_step": {
"default": "mdi:unfold-more-horizontal"
+ },
+ "clean_count": {
+ "default": "mdi:counter"
}
}
},
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
index 91e2a784af2..718b5ed7120 100644
--- a/homeassistant/components/tplink/light.py
+++ b/homeassistant/components/tplink/light.py
@@ -3,11 +3,12 @@
from __future__ import annotations
from collections.abc import Sequence
+from dataclasses import dataclass
import logging
from typing import Any
-from kasa import Device, DeviceType, LightState, Module
-from kasa.interfaces import Light, LightEffect
+from kasa import Device, DeviceType, KasaException, LightState, Module
+from kasa.interfaces import LightEffect
from kasa.iot import IotDevice
import voluptuous as vol
@@ -17,21 +18,32 @@ from homeassistant.components.light import (
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_TRANSITION,
+ DOMAIN as LIGHT_DOMAIN,
EFFECT_OFF,
ColorMode,
LightEntity,
+ LightEntityDescription,
LightEntityFeature,
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import TPLinkConfigEntry, legacy_device_id
+from .const import DOMAIN
from .coordinator import TPLinkDataUpdateCoordinator
-from .entity import CoordinatedTPLinkEntity, async_refresh_after
+from .entity import (
+ CoordinatedTPLinkModuleEntity,
+ TPLinkModuleEntityDescription,
+ async_refresh_after,
+)
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@@ -130,75 +142,122 @@ def _async_build_base_effect(
}
+def _get_backwards_compatible_light_unique_id(
+ device: Device, entity_description: TPLinkModuleEntityDescription
+) -> str:
+ """Return unique ID for the entity."""
+ # For historical reasons the light platform uses the mac address as
+ # the unique id whereas all other platforms use device_id.
+
+ # For backwards compat with pyHS100
+ if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice):
+ # Dimmers used to use the switch format since
+ # pyHS100 treated them as SmartPlug but the old code
+ # created them as lights
+ # https://github.com/home-assistant/core/blob/2021.9.7/ \
+ # homeassistant/components/tplink/common.py#L86
+ return legacy_device_id(device)
+
+ # Newer devices can have child lights. While there isn't currently
+ # an example of a device with more than one light we use the device_id
+ # for consistency and future proofing
+ if device.parent or device.children:
+ return legacy_device_id(device)
+
+ return device.mac.replace(":", "").upper()
+
+
+@dataclass(frozen=True, kw_only=True)
+class TPLinkLightEntityDescription(
+ LightEntityDescription, TPLinkModuleEntityDescription
+):
+ """Base class for tplink light entity description."""
+
+ unique_id_fn = _get_backwards_compatible_light_unique_id
+
+
+LIGHT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = (
+ TPLinkLightEntityDescription(
+ key="light",
+ exists_fn=lambda dev, _: Module.Light in dev.modules
+ and Module.LightEffect not in dev.modules,
+ ),
+)
+
+LIGHT_EFFECT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = (
+ TPLinkLightEntityDescription(
+ key="light_effect",
+ exists_fn=lambda dev, _: Module.Light in dev.modules
+ and Module.LightEffect in dev.modules,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up switches."""
+ """Set up lights."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
- entities: list[TPLinkLightEntity | TPLinkLightEffectEntity] = []
- if effect_module := device.modules.get(Module.LightEffect):
- entities.append(
- TPLinkLightEffectEntity(
- device,
- parent_coordinator,
- light_module=device.modules[Module.Light],
- effect_module=effect_module,
+
+ known_child_device_ids_light: set[str] = set()
+ known_child_device_ids_light_effect: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkLightEntity,
+ descriptions=LIGHT_DESCRIPTIONS,
+ platform_domain=LIGHT_DOMAIN,
+ known_child_device_ids=known_child_device_ids_light,
+ first_check=first_check,
+ )
+ entities.extend(
+ CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkLightEffectEntity,
+ descriptions=LIGHT_EFFECT_DESCRIPTIONS,
+ platform_domain=LIGHT_DOMAIN,
+ known_child_device_ids=known_child_device_ids_light_effect,
+ first_check=first_check,
)
)
- if effect_module.has_custom_effects:
- platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(
- SERVICE_RANDOM_EFFECT,
- RANDOM_EFFECT_DICT,
- "async_set_random_effect",
- )
- platform.async_register_entity_service(
- SERVICE_SEQUENCE_EFFECT,
- SEQUENCE_EFFECT_DICT,
- "async_set_sequence_effect",
- )
- elif Module.Light in device.modules:
- entities.append(
- TPLinkLightEntity(
- device, parent_coordinator, light_module=device.modules[Module.Light]
- )
- )
- entities.extend(
- TPLinkLightEntity(
- child,
- parent_coordinator,
- light_module=child.modules[Module.Light],
- parent=device,
- )
- for child in device.children
- if Module.Light in child.modules
- )
- async_add_entities(entities)
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
-class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
+class TPLinkLightEntity(CoordinatedTPLinkModuleEntity, LightEntity):
"""Representation of a TPLink Smart Bulb."""
_attr_supported_features = LightEntityFeature.TRANSITION
_fixed_color_mode: ColorMode | None = None
+ entity_description: TPLinkLightEntityDescription
+
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkLightEntityDescription,
*,
- light_module: Light,
parent: Device | None = None,
) -> None:
"""Initialize the light."""
- self._parent = parent
+ super().__init__(device, coordinator, description, parent=parent)
+
+ light_module = device.modules[Module.Light]
self._light_module = light_module
- # If _attr_name is None the entity name will be the device name
- self._attr_name = None if parent is None else device.alias
modes: set[ColorMode] = {ColorMode.ONOFF}
if color_temp_feat := light_module.get_feature("color_temp"):
modes.add(ColorMode.COLOR_TEMP)
@@ -213,31 +272,6 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
# If the light supports only a single color mode, set it now
self._fixed_color_mode = next(iter(self._attr_supported_color_modes))
- super().__init__(device, coordinator, parent=parent)
-
- def _get_unique_id(self) -> str:
- """Return unique ID for the entity."""
- # For historical reasons the light platform uses the mac address as
- # the unique id whereas all other platforms use device_id.
- device = self._device
-
- # For backwards compat with pyHS100
- if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice):
- # Dimmers used to use the switch format since
- # pyHS100 treated them as SmartPlug but the old code
- # created them as lights
- # https://github.com/home-assistant/core/blob/2021.9.7/ \
- # homeassistant/components/tplink/common.py#L86
- return legacy_device_id(device)
-
- # Newer devices can have child lights. While there isn't currently
- # an example of a device with more than one light we use the device_id
- # for consistency and future proofing
- if self._parent or device.children:
- return legacy_device_id(device)
-
- return device.mac.replace(":", "").upper()
-
@callback
def _async_extract_brightness_transition(
self, **kwargs: Any
@@ -355,19 +389,39 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
class TPLinkLightEffectEntity(TPLinkLightEntity):
"""Representation of a TPLink Smart Light Strip."""
+ _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
+
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkLightEntityDescription,
*,
- light_module: Light,
- effect_module: LightEffect,
+ parent: Device | None = None,
) -> None:
"""Initialize the light strip."""
- self._effect_module = effect_module
- super().__init__(device, coordinator, light_module=light_module)
+ super().__init__(device, coordinator, description, parent=parent)
- _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
+ self._effect_module = device.modules[Module.LightEffect]
+
+ async def async_added_to_hass(self) -> None:
+ """Call update attributes after the device is added to the platform."""
+ await super().async_added_to_hass()
+
+ self._register_effects_services()
+
+ def _register_effects_services(self) -> None:
+ if self._effect_module.has_custom_effects:
+ self.platform.async_register_entity_service(
+ SERVICE_RANDOM_EFFECT,
+ RANDOM_EFFECT_DICT,
+ "async_set_random_effect",
+ )
+ self.platform.async_register_entity_service(
+ SERVICE_SEQUENCE_EFFECT,
+ SEQUENCE_EFFECT_DICT,
+ "async_set_sequence_effect",
+ )
@callback
def _async_update_attrs(self) -> bool:
@@ -458,7 +512,17 @@ class TPLinkLightEffectEntity(TPLinkLightEntity):
if transition_range:
effect["transition_range"] = transition_range
effect["transition"] = 0
- await self._effect_module.set_custom_effect(effect)
+ try:
+ await self._effect_module.set_custom_effect(effect)
+ except KasaException as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_custom_effect",
+ translation_placeholders={
+ "effect": str(effect),
+ "exc": str(ex),
+ },
+ ) from ex
async def async_set_sequence_effect(
self,
@@ -480,4 +544,14 @@ class TPLinkLightEffectEntity(TPLinkLightEntity):
"spread": spread,
"direction": direction,
}
- await self._effect_module.set_custom_effect(effect)
+ try:
+ await self._effect_module.set_custom_effect(effect)
+ except KasaException as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_custom_effect",
+ translation_placeholders={
+ "effect": str(effect),
+ "exc": str(ex),
+ },
+ ) from ex
diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json
index a975e675ceb..ff65211c9b3 100644
--- a/homeassistant/components/tplink/manifest.json
+++ b/homeassistant/components/tplink/manifest.json
@@ -300,5 +300,6 @@
"documentation": "https://www.home-assistant.io/integrations/tplink",
"iot_class": "local_polling",
"loggers": ["kasa"],
- "requirements": ["python-kasa[speedups]==0.9.1"]
+ "quality_scale": "platinum",
+ "requirements": ["python-kasa[speedups]==0.10.1"]
}
diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py
deleted file mode 100644
index 389260a388b..00000000000
--- a/homeassistant/components/tplink/models.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""The tplink integration models."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from kasa import Credentials
-
-from .coordinator import TPLinkDataUpdateCoordinator
-
-
-@dataclass(slots=True)
-class TPLinkData:
- """Data for the tplink integration."""
-
- parent_coordinator: TPLinkDataUpdateCoordinator
- children_coordinators: list[TPLinkDataUpdateCoordinator]
- camera_credentials: Credentials | None
- live_view: bool | None
diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py
index 3f7fa9c3e0f..a9d002c0083 100644
--- a/homeassistant/components/tplink/number.py
+++ b/homeassistant/components/tplink/number.py
@@ -9,6 +9,7 @@ from typing import Final, cast
from kasa import Device, Feature
from homeassistant.components.number import (
+ DOMAIN as NUMBER_DOMAIN,
NumberEntity,
NumberEntityDescription,
NumberMode,
@@ -31,7 +32,12 @@ _LOGGER = logging.getLogger(__name__)
class TPLinkNumberEntityDescription(
NumberEntityDescription, TPLinkFeatureEntityDescription
):
- """Base class for a TPLink feature based sensor entity description."""
+ """Base class for a TPLink feature based number entity description."""
+
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
NUMBER_DESCRIPTIONS: Final = (
@@ -59,6 +65,14 @@ NUMBER_DESCRIPTIONS: Final = (
key="tilt_step",
mode=NumberMode.BOX,
),
+ TPLinkNumberEntityDescription(
+ key="power_protection_threshold",
+ mode=NumberMode.SLIDER,
+ ),
+ TPLinkNumberEntityDescription(
+ key="clean_count",
+ mode=NumberMode.SLIDER,
+ ),
)
NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS}
@@ -69,26 +83,34 @@ async def async_setup_entry(
config_entry: TPLinkConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up sensors."""
+ """Set up number entities."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
- children_coordinators = data.children_coordinators
device = parent_coordinator.device
- entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
- hass=hass,
- device=device,
- coordinator=parent_coordinator,
- feature_type=Feature.Type.Number,
- entity_class=TPLinkNumberEntity,
- descriptions=NUMBER_DESCRIPTIONS_MAP,
- child_coordinators=children_coordinators,
- )
+ known_child_device_ids: set[str] = set()
+ first_check = True
- async_add_entities(entities)
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ feature_type=Feature.Type.Number,
+ entity_class=TPLinkNumberEntity,
+ descriptions=NUMBER_DESCRIPTIONS_MAP,
+ platform_domain=NUMBER_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity):
- """Representation of a feature-based TPLink sensor."""
+ """Representation of a feature-based TPLink number entity."""
entity_description: TPLinkNumberEntityDescription
@@ -101,7 +123,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity):
description: TPLinkFeatureEntityDescription,
parent: Device | None = None,
) -> None:
- """Initialize the a switch."""
+ """Initialize the number entity."""
super().__init__(
device, coordinator, feature=feature, description=description, parent=parent
)
diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml
new file mode 100644
index 00000000000..f120945771c
--- /dev/null
+++ b/homeassistant/components/tplink/quality_scale.yaml
@@ -0,0 +1,66 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry: done
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: The integration does not use events.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: The integration only uses platform services.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions: done
+ brands: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: The integration does not have any options configuration parameters.
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices: done
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: done
+ discovery-update-info: done
+ repair-issues: done
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: done
+ docs-examples: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py
index 5dd8e54fca8..8e9dee7b964 100644
--- a/homeassistant/components/tplink/select.py
+++ b/homeassistant/components/tplink/select.py
@@ -7,7 +7,11 @@ from typing import Final, cast
from kasa import Device, Feature
-from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.components.select import (
+ DOMAIN as SELECT_DOMAIN,
+ SelectEntity,
+ SelectEntityDescription,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -24,9 +28,13 @@ from .entity import (
class TPLinkSelectEntityDescription(
SelectEntityDescription, TPLinkFeatureEntityDescription
):
- """Base class for a TPLink feature based sensor entity description."""
+ """Base class for a TPLink feature based select entity description."""
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
SELECT_DESCRIPTIONS: Final = [
TPLinkSelectEntityDescription(
key="light_preset",
@@ -47,22 +55,30 @@ async def async_setup_entry(
config_entry: TPLinkConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up sensors."""
+ """Set up select entities."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
- children_coordinators = data.children_coordinators
device = parent_coordinator.device
+ known_child_device_ids: set[str] = set()
+ first_check = True
- entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
- hass=hass,
- device=device,
- coordinator=parent_coordinator,
- feature_type=Feature.Type.Choice,
- entity_class=TPLinkSelectEntity,
- descriptions=SELECT_DESCRIPTIONS_MAP,
- child_coordinators=children_coordinators,
- )
- async_add_entities(entities)
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ feature_type=Feature.Type.Choice,
+ entity_class=TPLinkSelectEntity,
+ descriptions=SELECT_DESCRIPTIONS_MAP,
+ platform_domain=SELECT_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity):
diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py
index da4bf72122d..9b21ba775a9 100644
--- a/homeassistant/components/tplink/sensor.py
+++ b/homeassistant/components/tplink/sensor.py
@@ -2,10 +2,13 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING, cast
+from operator import methodcaller
+from typing import TYPE_CHECKING, Any, cast
from kasa import Feature
+from kasa.smart.modules.clean import ErrorCode as VacuumError
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
@@ -14,12 +17,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
+from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry
from .const import UNIT_MAPPING
-from .deprecate import async_cleanup_deprecated
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@@ -29,6 +32,14 @@ class TPLinkSensorEntityDescription(
):
"""Base class for a TPLink feature based sensor entity description."""
+ #: Optional callable to convert the value
+ convert_fn: Callable[[Any], Any] | None = None
+
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+_TOTAL_SECONDS_METHOD_CALLER = methodcaller("total_seconds")
SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = (
TPLinkSensorEntityDescription(
@@ -113,11 +124,145 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = (
TPLinkSensorEntityDescription(
key="alarm_source",
),
+ # Vacuum cleaning records
TPLinkSensorEntityDescription(
- key="temperature",
- device_class=SensorDeviceClass.TEMPERATURE,
+ key="clean_time",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.MINUTES,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ key="clean_area",
+ device_class=SensorDeviceClass.AREA,
state_class=SensorStateClass.MEASUREMENT,
),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="clean_progress",
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TPLinkSensorEntityDescription(
+ key="last_clean_time",
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.MINUTES,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ key="last_clean_area",
+ device_class=SensorDeviceClass.AREA,
+ ),
+ TPLinkSensorEntityDescription(
+ key="last_clean_timestamp",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="total_clean_time",
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.MINUTES,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="total_clean_area",
+ device_class=SensorDeviceClass.AREA,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TPLinkSensorEntityDescription(
+ key="total_clean_count",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="main_brush_remaining",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="main_brush_used",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="side_brush_remaining",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="side_brush_used",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="filter_remaining",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="filter_used",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="sensor_remaining",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="sensor_used",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="charging_contacts_remaining",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ entity_registry_enabled_default=False,
+ key="charging_contacts_used",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ convert_fn=_TOTAL_SECONDS_METHOD_CALLER,
+ ),
+ TPLinkSensorEntityDescription(
+ key="vacuum_error",
+ device_class=SensorDeviceClass.ENUM,
+ options=[name.lower() for name in VacuumError._member_names_],
+ convert_fn=lambda x: x.name.lower(),
+ ),
)
SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS}
@@ -131,20 +276,27 @@ async def async_setup_entry(
"""Set up sensors."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
- children_coordinators = data.children_coordinators
device = parent_coordinator.device
+ known_child_device_ids: set[str] = set()
+ first_check = True
- entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
- hass=hass,
- device=device,
- coordinator=parent_coordinator,
- feature_type=Feature.Type.Sensor,
- entity_class=TPLinkSensorEntity,
- descriptions=SENSOR_DESCRIPTIONS_MAP,
- child_coordinators=children_coordinators,
- )
- async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities)
- async_add_entities(entities)
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ feature_type=Feature.Type.Sensor,
+ entity_class=TPLinkSensorEntity,
+ descriptions=SENSOR_DESCRIPTIONS_MAP,
+ platform_domain=SENSOR_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity):
@@ -161,6 +313,9 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity):
# We probably do not need this, when we are rounding already?
self._attr_suggested_display_precision = self._feature.precision_hint
+ if self.entity_description.convert_fn:
+ value = self.entity_description.convert_fn(value)
+
if TYPE_CHECKING:
# pylint: disable-next=import-outside-toplevel
from datetime import date, datetime
diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py
index 141ea696358..027fa2dd58f 100644
--- a/homeassistant/components/tplink/siren.py
+++ b/homeassistant/components/tplink/siren.py
@@ -1,19 +1,61 @@
-"""Support for TPLink hub alarm."""
+"""Support for TPLink siren entity."""
from __future__ import annotations
-from typing import Any
+from collections.abc import Callable
+from dataclasses import dataclass
+import math
+from typing import TYPE_CHECKING, Any, cast
from kasa import Device, Module
-from kasa.smart.modules.alarm import Alarm
-from homeassistant.components.siren import SirenEntity, SirenEntityFeature
+from homeassistant.components.siren import (
+ ATTR_DURATION,
+ ATTR_TONE,
+ ATTR_VOLUME_LEVEL,
+ DOMAIN as SIREN_DOMAIN,
+ SirenEntity,
+ SirenEntityDescription,
+ SirenEntityFeature,
+ SirenTurnOnServiceParameters,
+)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import TPLinkConfigEntry
+from . import TPLinkConfigEntry, legacy_device_id
+from .const import DOMAIN
from .coordinator import TPLinkDataUpdateCoordinator
-from .entity import CoordinatedTPLinkEntity, async_refresh_after
+from .entity import (
+ CoordinatedTPLinkModuleEntity,
+ TPLinkModuleEntityDescription,
+ async_refresh_after,
+)
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class TPLinkSirenEntityDescription(
+ SirenEntityDescription, TPLinkModuleEntityDescription
+):
+ """Base class for siren entity description."""
+
+ unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = (
+ lambda device, desc: legacy_device_id(device)
+ if desc.key == "siren"
+ else f"{legacy_device_id(device)}-{desc.key}"
+ )
+
+
+SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = (
+ TPLinkSirenEntityDescription(
+ key="siren",
+ exists_fn=lambda dev, _: Module.Alarm in dev.modules,
+ ),
+)
async def async_setup_entry(
@@ -26,29 +68,85 @@ async def async_setup_entry(
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
- if Module.Alarm in device.modules:
- async_add_entities([TPLinkSirenEntity(device, parent_coordinator)])
+ known_child_device_ids: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkSirenEntity,
+ descriptions=SIREN_DESCRIPTIONS,
+ platform_domain=SIREN_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
-class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity):
- """Representation of a tplink hub alarm."""
+class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity):
+ """Representation of a tplink siren entity."""
_attr_name = None
- _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
+ _attr_supported_features = (
+ SirenEntityFeature.TURN_OFF
+ | SirenEntityFeature.TURN_ON
+ | SirenEntityFeature.TONES
+ | SirenEntityFeature.DURATION
+ | SirenEntityFeature.VOLUME_SET
+ )
+
+ entity_description: TPLinkSirenEntityDescription
def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkSirenEntityDescription,
+ *,
+ parent: Device | None = None,
) -> None:
"""Initialize the siren entity."""
- self._alarm_module: Alarm = device.modules[Module.Alarm]
- super().__init__(device, coordinator)
+ super().__init__(device, coordinator, description, parent=parent)
+ self._alarm_module = device.modules[Module.Alarm]
+
+ alarm_vol_feat = self._alarm_module.get_feature("alarm_volume")
+ alarm_duration_feat = self._alarm_module.get_feature("alarm_duration")
+ if TYPE_CHECKING:
+ assert alarm_vol_feat
+ assert alarm_duration_feat
+ self._alarm_volume_max = alarm_vol_feat.maximum_value
+ self._alarm_duration_max = alarm_duration_feat.maximum_value
@async_refresh_after
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
- await self._alarm_module.play()
+ turn_on_params = cast(SirenTurnOnServiceParameters, kwargs)
+ if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
+ # service parameter is a % so we round up to the nearest int
+ volume = math.ceil(volume * self._alarm_volume_max)
+
+ if (duration := kwargs.get(ATTR_DURATION)) is not None:
+ if duration < 1 or duration > self._alarm_duration_max:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_alarm_duration",
+ translation_placeholders={
+ "duration": str(duration),
+ "duration_max": str(self._alarm_duration_max),
+ },
+ )
+
+ await self._alarm_module.play(
+ duration=turn_on_params.get(ATTR_DURATION),
+ volume=volume,
+ sound=kwargs.get(ATTR_TONE),
+ )
@async_refresh_after
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -59,4 +157,8 @@ class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity):
def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
self._attr_is_on = self._alarm_module.active
+ # alarm_sounds returns list[str], so we need to widen the type
+ self._attr_available_tones = cast(
+ list[str | int], self._alarm_module.alarm_sounds
+ )
return True
diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json
index c0aef09e8c3..ded4806a726 100644
--- a/homeassistant/components/tplink/strings.json
+++ b/homeassistant/components/tplink/strings.json
@@ -14,6 +14,9 @@
"pick_device": {
"data": {
"device": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "device": "Pick the TP-Link device to add."
}
},
"discovery_confirm": {
@@ -25,6 +28,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "Your TP-Link cloud username which is the full email and is case sensitive.",
+ "password": "Your TP-Link cloud password which is case sensitive."
}
},
"discovery_auth_confirm": {
@@ -33,6 +40,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::tplink::config::step::user_auth_confirm::data_description::username%]",
+ "password": "[%key:component::tplink::config::step::user_auth_confirm::data_description::password%]"
}
},
"reauth_confirm": {
@@ -41,6 +52,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::tplink::config::step::user_auth_confirm::data_description::username%]",
+ "password": "[%key:component::tplink::config::step::user_auth_confirm::data_description::password%]"
}
},
"reconfigure": {
@@ -48,15 +63,23 @@
"description": "Update your configuration for device {mac}",
"data": {
"host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "[%key:component::tplink::config::step::user::data_description::host%]"
}
},
"camera_auth_confirm": {
"title": "Set camera account credentials",
- "description": "Input device camera account credentials. Leave blank if they are the same as your TPLink cloud credentials.",
+ "description": "Input device camera account credentials.",
"data": {
"live_view": "Enable camera live view",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "live_view": "Enabling live view will create the live view camera entity and requires your camera account credentials.",
+ "username": "Your camera account username configured for the device in the Tapo app.",
+ "password": "Your camera account password configured for the device in the Tapo app."
}
}
},
@@ -86,26 +109,12 @@
"overheated": {
"name": "Overheated"
},
- "battery_low": {
- "name": "Battery low"
+ "overloaded": {
+ "name": "Overloaded"
},
"cloud_connection": {
"name": "Cloud connection"
},
- "update_available": {
- "name": "[%key:component::binary_sensor::entity_component::update::name%]",
- "state": {
- "off": "[%key:component::binary_sensor::entity_component::update::state::off%]",
- "on": "[%key:component::binary_sensor::entity_component::update::state::on%]"
- }
- },
- "is_open": {
- "name": "[%key:component::binary_sensor::entity_component::door::name%]",
- "state": {
- "off": "[%key:common::state::closed%]",
- "on": "[%key:common::state::open%]"
- }
- },
"water_alert": {
"name": "[%key:component::binary_sensor::entity_component::moisture::name%]",
"state": {
@@ -132,6 +141,27 @@
},
"tilt_down": {
"name": "Tilt down"
+ },
+ "pair": {
+ "name": "Pair new device"
+ },
+ "unpair": {
+ "name": "Unpair device"
+ },
+ "main_brush_reset": {
+ "name": "Reset main brush consumable"
+ },
+ "side_brush_reset": {
+ "name": "Reset side brush consumable"
+ },
+ "sensor_reset": {
+ "name": "Reset sensor consumable"
+ },
+ "filter_reset": {
+ "name": "Reset filter consumable"
+ },
+ "charging_contacts_reset": {
+ "name": "Reset charging contacts consumable"
}
},
"camera": {
@@ -172,27 +202,6 @@
"signal_level": {
"name": "Signal level"
},
- "current_firmware_version": {
- "name": "Current firmware version"
- },
- "available_firmware_version": {
- "name": "Available firmware version"
- },
- "battery_level": {
- "name": "Battery level"
- },
- "temperature": {
- "name": "[%key:component::sensor::entity_component::temperature::name%]"
- },
- "voltage": {
- "name": "[%key:component::sensor::entity_component::voltage::name%]"
- },
- "current": {
- "name": "[%key:component::sensor::entity_component::current::name%]"
- },
- "humidity": {
- "name": "[%key:component::sensor::entity_component::humidity::name%]"
- },
"device_time": {
"name": "Device time"
},
@@ -208,8 +217,79 @@
"alarm_source": {
"name": "Alarm source"
},
- "rssi": {
- "name": "[%key:component::sensor::entity_component::signal_strength::name%]"
+ "clean_area": {
+ "name": "Cleaning area"
+ },
+ "clean_time": {
+ "name": "Cleaning time"
+ },
+ "clean_progress": {
+ "name": "Cleaning progress"
+ },
+ "total_clean_area": {
+ "name": "Total cleaning area"
+ },
+ "total_clean_time": {
+ "name": "Total cleaning time"
+ },
+ "total_clean_count": {
+ "name": "Total cleaning count"
+ },
+ "last_clean_area": {
+ "name": "Last cleaned area"
+ },
+ "last_clean_time": {
+ "name": "Last cleaned time"
+ },
+ "last_clean_timestamp": {
+ "name": "Last clean start"
+ },
+ "main_brush_remaining": {
+ "name": "Main brush remaining"
+ },
+ "main_brush_used": {
+ "name": "Main brush used"
+ },
+ "side_brush_remaining": {
+ "name": "Side brush remaining"
+ },
+ "side_brush_used": {
+ "name": "Side brush used"
+ },
+ "filter_remaining": {
+ "name": "Filter remaining"
+ },
+ "filter_used": {
+ "name": "Filter used"
+ },
+ "sensor_remaining": {
+ "name": "Sensor remaining"
+ },
+ "sensor_used": {
+ "name": "Sensor used"
+ },
+ "charging_contacts_remaining": {
+ "name": "Charging contacts remaining"
+ },
+ "charging_contacts_used": {
+ "name": "Charging contacts used"
+ },
+ "vacuum_error": {
+ "name": "Error",
+ "state": {
+ "ok": "No error",
+ "sidebrushstuck": "Side brush stuck",
+ "mainbrushstuck": "Main brush stuck",
+ "wheelblocked": "Wheel blocked",
+ "trapped": "Unable to move",
+ "trappedcliff": "Unable to move (cliff sensor)",
+ "dustbinremoved": "Missing dust bin",
+ "unabletomove": "Unable to move",
+ "lidarblocked": "Lidar blocked",
+ "unabletofinddock": "Unable to find dock",
+ "batterylow": "Low on battery",
+ "unknowninternal": "Unknown error, report to upstream"
+ }
}
},
"switch": {
@@ -245,6 +325,9 @@
},
"baby_cry_detection": {
"name": "Baby cry detection"
+ },
+ "carpet_boost": {
+ "name": "Carpet boost"
}
},
"number": {
@@ -260,12 +343,38 @@
"temperature_offset": {
"name": "Temperature offset"
},
+ "power_protection_threshold": {
+ "name": "Power protection"
+ },
"pan_step": {
"name": "Pan degrees"
},
"tilt_step": {
"name": "Tilt degrees"
+ },
+ "clean_count": {
+ "name": "Clean count"
}
+ },
+ "vacuum": {
+ "vacuum": {
+ "state_attributes": {
+ "fan_speed": {
+ "state": {
+ "quiet": "Quiet",
+ "standard": "Standard",
+ "turbo": "Turbo",
+ "max": "Max",
+ "ultra": "Ultra"
+ }
+ }
+ }
+ }
+ }
+ },
+ "device": {
+ "unnamed_device": {
+ "name": "Unnamed {model}"
}
},
"services": {
@@ -371,6 +480,18 @@
},
"device_authentication": {
"message": "Device authentication error {func}: {exc}"
+ },
+ "set_custom_effect": {
+ "message": "Error trying to set custom effect {effect}: {exc}"
+ },
+ "unexpected_device": {
+ "message": "Unexpected device found at {host}; expected {expected}, found {found}"
+ },
+ "unsupported_mode": {
+ "message": "Tried to set unsupported mode: {mode}"
+ },
+ "invalid_alarm_duration": {
+ "message": "Invalid duration {duration} available: 1-{duration_max}s"
}
},
"issues": {
diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py
index 7a879fb3c70..f08753def26 100644
--- a/homeassistant/components/tplink/switch.py
+++ b/homeassistant/components/tplink/switch.py
@@ -8,7 +8,11 @@ from typing import Any, cast
from kasa import Feature
-from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -26,9 +30,13 @@ _LOGGER = logging.getLogger(__name__)
class TPLinkSwitchEntityDescription(
SwitchEntityDescription, TPLinkFeatureEntityDescription
):
- """Base class for a TPLink feature based sensor entity description."""
+ """Base class for a TPLink feature based switch entity description."""
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = (
TPLinkSwitchEntityDescription(
key="state",
@@ -66,6 +74,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = (
TPLinkSwitchEntityDescription(
key="baby_cry_detection",
),
+ TPLinkSwitchEntityDescription(
+ key="carpet_boost",
+ ),
)
SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS}
@@ -80,17 +91,26 @@ async def async_setup_entry(
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
+ known_child_device_ids: set[str] = set()
+ first_check = True
- entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
- hass=hass,
- device=device,
- coordinator=parent_coordinator,
- feature_type=Feature.Switch,
- entity_class=TPLinkSwitch,
- descriptions=SWITCH_DESCRIPTIONS_MAP,
- )
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ feature_type=Feature.Switch,
+ entity_class=TPLinkSwitch,
+ descriptions=SWITCH_DESCRIPTIONS_MAP,
+ platform_domain=SWITCH_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
- async_add_entities(entities)
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity):
diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py
new file mode 100644
index 00000000000..c62cd1d27c8
--- /dev/null
+++ b/homeassistant/components/tplink/vacuum.py
@@ -0,0 +1,162 @@
+"""Support for TPLink vacuum."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from kasa import Device, Module
+from kasa.smart.modules.clean import Clean, Status
+
+from homeassistant.components.vacuum import (
+ DOMAIN as VACUUM_DOMAIN,
+ StateVacuumEntity,
+ StateVacuumEntityDescription,
+ VacuumActivity,
+ VacuumEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import TPLinkConfigEntry
+from .coordinator import TPLinkDataUpdateCoordinator
+from .entity import (
+ CoordinatedTPLinkModuleEntity,
+ TPLinkModuleEntityDescription,
+ async_refresh_after,
+)
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
+# Upstream state to VacuumActivity
+STATUS_TO_ACTIVITY = {
+ Status.Idle: VacuumActivity.IDLE,
+ Status.Cleaning: VacuumActivity.CLEANING,
+ Status.GoingHome: VacuumActivity.RETURNING,
+ Status.Charging: VacuumActivity.DOCKED,
+ Status.Charged: VacuumActivity.DOCKED,
+ Status.Undocked: VacuumActivity.IDLE,
+ Status.Paused: VacuumActivity.PAUSED,
+ Status.Error: VacuumActivity.ERROR,
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class TPLinkVacuumEntityDescription(
+ StateVacuumEntityDescription, TPLinkModuleEntityDescription
+):
+ """Base class for vacuum entity description."""
+
+
+VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = (
+ TPLinkVacuumEntityDescription(
+ key="vacuum",
+ translation_key="vacuum",
+ exists_fn=lambda dev, _: Module.Clean in dev.modules,
+ entity_name_fn=lambda _, __: None,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: TPLinkConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up vacuum entities."""
+ data = config_entry.runtime_data
+ parent_coordinator = data.parent_coordinator
+ device = parent_coordinator.device
+
+ known_child_device_ids: set[str] = set()
+ first_check = True
+
+ def _check_device() -> None:
+ entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children(
+ hass=hass,
+ device=device,
+ coordinator=parent_coordinator,
+ entity_class=TPLinkVacuumEntity,
+ descriptions=VACUUM_DESCRIPTIONS,
+ platform_domain=VACUUM_DOMAIN,
+ known_child_device_ids=known_child_device_ids,
+ first_check=first_check,
+ )
+ async_add_entities(entities)
+
+ _check_device()
+ first_check = False
+ config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
+
+
+class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity):
+ """Representation of a tplink vacuum."""
+
+ _attr_supported_features = (
+ VacuumEntityFeature.STATE
+ | VacuumEntityFeature.BATTERY
+ | VacuumEntityFeature.START
+ | VacuumEntityFeature.PAUSE
+ | VacuumEntityFeature.RETURN_HOME
+ )
+
+ entity_description: TPLinkVacuumEntityDescription
+
+ def __init__(
+ self,
+ device: Device,
+ coordinator: TPLinkDataUpdateCoordinator,
+ description: TPLinkVacuumEntityDescription,
+ *,
+ parent: Device,
+ ) -> None:
+ """Initialize the vacuum entity."""
+ super().__init__(device, coordinator, description, parent=parent)
+ self._vacuum_module: Clean = device.modules[Module.Clean]
+ if speaker := device.modules.get(Module.Speaker):
+ self._speaker_module = speaker
+ self._attr_supported_features |= VacuumEntityFeature.LOCATE
+
+ if (
+ fanspeed_feat := self._vacuum_module.get_feature("fan_speed_preset")
+ ) and fanspeed_feat.choices:
+ self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
+ self._attr_fan_speed_list = [c.lower() for c in fanspeed_feat.choices]
+
+ @async_refresh_after
+ async def async_start(self) -> None:
+ """Start cleaning."""
+ await self._vacuum_module.start()
+
+ @async_refresh_after
+ async def async_pause(self) -> None:
+ """Pause cleaning."""
+ await self._vacuum_module.pause()
+
+ @async_refresh_after
+ async def async_return_to_base(self, **kwargs: Any) -> None:
+ """Return home."""
+ await self._vacuum_module.return_home()
+
+ @async_refresh_after
+ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
+ """Set fan speed."""
+ await self._vacuum_module.set_fan_speed_preset(fan_speed.capitalize())
+
+ async def async_locate(self, **kwargs: Any) -> None:
+ """Locate the device."""
+ await self._speaker_module.locate()
+
+ @property
+ def battery_level(self) -> int | None:
+ """Return battery level."""
+ return self._vacuum_module.battery
+
+ def _async_update_attrs(self) -> bool:
+ """Update the entity's attributes."""
+ self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status)
+ if self._vacuum_module.has_feature("fan_speed_preset"):
+ self._attr_fan_speed = self._vacuum_module.fan_speed_preset.lower()
+ return True
diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py
index fe08c3db234..5b9bc2551b7 100644
--- a/homeassistant/components/traccar/__init__.py
+++ b/homeassistant/components/traccar/__init__.py
@@ -9,8 +9,7 @@ from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py
index 9ff645ce4d6..bb0f3e5251a 100644
--- a/homeassistant/components/trace/__init__.py
+++ b/homeassistant/components/trace/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import ExtendedJSONEncoder
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py
index e8ef417ca5f..3c503efdd28 100644
--- a/homeassistant/components/trace/models.py
+++ b/homeassistant/components/trace/models.py
@@ -15,9 +15,8 @@ from homeassistant.helpers.trace import (
trace_id_set,
trace_set_child_id,
)
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, uuid as uuid_util
from homeassistant.util.limited_size_dict import LimitedSizeDict
-import homeassistant.util.uuid as uuid_util
type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]]
diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py
index 0060310e6c2..92ed2ea8b82 100644
--- a/homeassistant/components/tradfri/__init__.py
+++ b/homeassistant/components/tradfri/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
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.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py
index d9911472a67..29d876346a7 100644
--- a/homeassistant/components/tradfri/config_flow.py
+++ b/homeassistant/components/tradfri/config_flow.py
@@ -10,10 +10,13 @@ from pytradfri import Gateway, RequestError
from pytradfri.api.aiocoap_api import APIFactory
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo,
+)
from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN
@@ -78,12 +81,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle homekit discovery."""
- await self.async_set_unique_id(
- discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID]
- )
+ await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID])
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host})
host = discovery_info.host
@@ -96,7 +97,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if not entry.unique_id:
self.hass.config_entries.async_update_entry(
entry,
- unique_id=discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID],
+ unique_id=discovery_info.properties[ATTR_PROPERTIES_ID],
)
return self.async_abort(reason="already_configured")
diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py
index a71691e6e90..e464d1a8142 100644
--- a/homeassistant/components/tradfri/light.py
+++ b/homeassistant/components/tradfri/light.py
@@ -20,7 +20,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .coordinator import TradfriDeviceDataUpdateCoordinator
diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py
index 614072cc706..fc5588f40ac 100644
--- a/homeassistant/components/trafikverket_camera/__init__.py
+++ b/homeassistant/components/trafikverket_camera/__init__.py
@@ -34,12 +34,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) ->
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool:
"""Unload Trafikverket Camera config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool:
"""Migrate old entry."""
api_key = entry.data[CONF_API_KEY]
web_session = async_get_clientsession(hass)
diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py
index 1ae48732c88..ece02cacf70 100644
--- a/homeassistant/components/trafikverket_camera/camera.py
+++ b/homeassistant/components/trafikverket_camera/camera.py
@@ -15,6 +15,8 @@ from .const import ATTR_DESCRIPTION, ATTR_TYPE
from .coordinator import TVDataUpdateCoordinator
from .entity import TrafikverketCameraEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/trafikverket_ferry/__init__.py b/homeassistant/components/trafikverket_ferry/__init__.py
index dbcbc1a4aba..ac9b1bd95ae 100644
--- a/homeassistant/components/trafikverket_ferry/__init__.py
+++ b/homeassistant/components/trafikverket_ferry/__init__.py
@@ -14,7 +14,7 @@ TVFerryConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> bool:
"""Set up Trafikverket Ferry from a config entry."""
- coordinator = TVDataUpdateCoordinator(hass)
+ coordinator = TVDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -22,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> b
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> bool:
"""Unload Trafikverket Ferry config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py
index fdde6766185..59b6bb4aaa3 100644
--- a/homeassistant/components/trafikverket_ferry/coordinator.py
+++ b/homeassistant/components/trafikverket_ferry/coordinator.py
@@ -52,21 +52,22 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator):
config_entry: TVFerryConfigEntry
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: TVFerryConfigEntry) -> None:
"""Initialize the Trafikverket coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=TIME_BETWEEN_UPDATES,
)
self._ferry_api = TrafikverketFerry(
- async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY]
+ async_get_clientsession(hass), config_entry.data[CONF_API_KEY]
)
- self._from: str = self.config_entry.data[CONF_FROM]
- self._to: str = self.config_entry.data[CONF_TO]
- 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._from: str = config_entry.data[CONF_FROM]
+ self._to: str = config_entry.data[CONF_TO]
+ self._time: time | None = dt_util.parse_time(config_entry.data[CONF_TIME])
+ self._weekdays: list[str] = config_entry.data[CONF_WEEKDAY]
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from Trafikverket."""
diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py
index 5a13159ecfd..44176ab82b7 100644
--- a/homeassistant/components/trafikverket_ferry/sensor.py
+++ b/homeassistant/components/trafikverket_ferry/sensor.py
@@ -31,6 +31,8 @@ ATTR_OTHER_INFO = "other_info"
SCAN_INTERVAL = timedelta(minutes=5)
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class TrafikverketSensorEntityDescription(SensorEntityDescription):
diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py
index 23aee50d816..19f88817e71 100644
--- a/homeassistant/components/trafikverket_train/__init__.py
+++ b/homeassistant/components/trafikverket_train/__init__.py
@@ -4,11 +4,21 @@ from __future__ import annotations
import logging
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from pytrafikverket import (
+ InvalidAuthentication,
+ NoTrainStationFound,
+ TrafikverketTrain,
+ UnknownError,
+)
-from .const import PLATFORMS
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+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 .coordinator import TVDataUpdateCoordinator
TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
@@ -19,7 +29,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool:
"""Set up Trafikverket Train from a config entry."""
- coordinator = TVDataUpdateCoordinator(hass)
+ coordinator = TVDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -37,13 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> b
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool:
"""Unload Trafikverket Weatherstation config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: TVTrainConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@@ -52,13 +62,55 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) ->
"""Migrate config entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
- if entry.version > 1:
+ if entry.version > 2:
# This means the user has downgraded from a future version
return False
- if entry.version == 1 and entry.minor_version == 1:
- # Remove unique id
- hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2)
+ if entry.version == 1:
+ if entry.minor_version == 1:
+ # Remove unique id
+ hass.config_entries.async_update_entry(
+ entry, unique_id=None, minor_version=2
+ )
+
+ # Change from station names to station signatures
+ try:
+ web_session = async_get_clientsession(hass)
+ train_api = TrafikverketTrain(web_session, entry.data[CONF_API_KEY])
+ from_stations = await train_api.async_search_train_stations(
+ entry.data[CONF_FROM]
+ )
+ to_stations = await train_api.async_search_train_stations(
+ entry.data[CONF_TO]
+ )
+ except InvalidAuthentication as error:
+ raise ConfigEntryAuthFailed from error
+ except NoTrainStationFound as error:
+ _LOGGER.error(
+ "Migration failed as no train station found with provided name %s",
+ str(error),
+ )
+ return False
+ except UnknownError as error:
+ _LOGGER.error("Unknown error occurred during validation %s", str(error))
+ return False
+ except Exception as error: # noqa: BLE001
+ _LOGGER.error("Unknown exception occurred during validation %s", str(error))
+ return False
+
+ if len(from_stations) > 1 or len(to_stations) > 1:
+ _LOGGER.error(
+ "Migration failed as more than one station found with provided name"
+ )
+ return False
+
+ new_data = entry.data.copy()
+ new_data[CONF_FROM] = from_stations[0].signature
+ new_data[CONF_TO] = to_stations[0].signature
+
+ hass.config_entries.async_update_entry(
+ entry, data=new_data, version=2, minor_version=1
+ )
_LOGGER.debug(
"Migration to version %s.%s successful",
diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py
index 363b9bb2542..57d74eef78a 100644
--- a/homeassistant/components/trafikverket_train/config_flow.py
+++ b/homeassistant/components/trafikverket_train/config_flow.py
@@ -3,21 +3,20 @@
from __future__ import annotations
from collections.abc import Mapping
-from datetime import datetime
import logging
from typing import Any
-from pytrafikverket import TrafikverketTrain
-from pytrafikverket.exceptions import (
+from pytrafikverket import (
InvalidAuthentication,
- MultipleTrainStationsFound,
- NoTrainAnnouncementFound,
NoTrainStationFound,
+ StationInfoModel,
+ TrafikverketTrain,
UnknownError,
)
import voluptuous as vol
from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
@@ -25,19 +24,18 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
+ SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TimeSelector,
)
-import homeassistant.util.dt as dt_util
from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
-from .util import next_departuredate
_LOGGER = logging.getLogger(__name__)
@@ -68,49 +66,23 @@ DATA_SCHEMA_REAUTH = vol.Schema(
)
-async def validate_input(
+async def validate_station(
hass: HomeAssistant,
api_key: str,
- train_from: str,
- train_to: str,
- train_time: str | None,
- weekdays: list[str],
- product_filter: str | None,
-) -> dict[str, str]:
+ train_station: str,
+ field: str,
+) -> tuple[list[StationInfoModel], dict[str, str]]:
"""Validate input from user input."""
errors: dict[str, str] = {}
-
- when = dt_util.now()
- if train_time:
- departure_day = next_departuredate(weekdays)
- if _time := dt_util.parse_time(train_time):
- when = datetime.combine(
- departure_day,
- _time,
- dt_util.get_default_time_zone(),
- )
-
+ stations = []
try:
web_session = async_get_clientsession(hass)
train_api = TrafikverketTrain(web_session, api_key)
- from_station = await train_api.async_search_train_station(train_from)
- to_station = await train_api.async_search_train_station(train_to)
- if train_time:
- await train_api.async_get_train_stop(
- from_station, to_station, when, product_filter
- )
- else:
- await train_api.async_get_next_train_stop(
- from_station, to_station, when, product_filter
- )
+ stations = await train_api.async_search_train_stations(train_station)
except InvalidAuthentication:
errors["base"] = "invalid_auth"
except NoTrainStationFound:
- errors["base"] = "invalid_station"
- except MultipleTrainStationsFound:
- errors["base"] = "more_stations"
- except NoTrainAnnouncementFound:
- errors["base"] = "no_trains"
+ errors[field] = "invalid_station"
except UnknownError as error:
_LOGGER.error("Unknown error occurred during validation %s", str(error))
errors["base"] = "cannot_connect"
@@ -118,14 +90,18 @@ async def validate_input(
_LOGGER.error("Unknown exception occurred during validation %s", str(error))
errors["base"] = "cannot_connect"
- return errors
+ return (stations, errors)
class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Trafikverket Train integration."""
- VERSION = 1
- MINOR_VERSION = 2
+ VERSION = 2
+ MINOR_VERSION = 1
+
+ _from_stations: list[StationInfoModel]
+ _to_stations: list[StationInfoModel]
+ _data: dict[str, Any]
@staticmethod
@callback
@@ -151,14 +127,11 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
api_key = user_input[CONF_API_KEY]
reauth_entry = self._get_reauth_entry()
- errors = await validate_input(
+ _, errors = await validate_station(
self.hass,
api_key,
reauth_entry.data[CONF_FROM],
- reauth_entry.data[CONF_TO],
- reauth_entry.data.get(CONF_TIME),
- reauth_entry.data[CONF_WEEKDAY],
- reauth_entry.options.get(CONF_FILTER_PRODUCT),
+ CONF_FROM,
)
if not errors:
return self.async_update_reload_and_abort(
@@ -174,6 +147,18 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step."""
+ return await self.async_step_initial(user_input)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step."""
+ return await self.async_step_initial(user_input)
+
+ async def async_step_initial(
+ self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
errors: dict[str, str] = {}
@@ -193,27 +178,99 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
if train_time:
name = f"{train_from} to {train_to} at {train_time}"
- errors = await validate_input(
- self.hass,
- api_key,
- train_from,
- train_to,
- train_time,
- train_days,
- filter_product,
+ self._from_stations, from_errors = await validate_station(
+ self.hass, api_key, train_from, CONF_FROM
)
+ self._to_stations, to_errors = await validate_station(
+ self.hass, api_key, train_to, CONF_TO
+ )
+ errors = {**from_errors, **to_errors}
+
if not errors:
- self._async_abort_entries_match(
- {
- CONF_API_KEY: api_key,
- CONF_FROM: train_from,
- CONF_TO: train_to,
- CONF_TIME: train_time,
- CONF_WEEKDAY: train_days,
- CONF_FILTER_PRODUCT: filter_product,
- }
+ if len(self._from_stations) == 1 and len(self._to_stations) == 1:
+ self._async_abort_entries_match(
+ {
+ CONF_API_KEY: api_key,
+ CONF_FROM: self._from_stations[0].signature,
+ CONF_TO: self._to_stations[0].signature,
+ CONF_TIME: train_time,
+ CONF_WEEKDAY: train_days,
+ CONF_FILTER_PRODUCT: filter_product,
+ }
+ )
+
+ if self.source == SOURCE_RECONFIGURE:
+ reconfigure_entry = self._get_reconfigure_entry()
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ title=name,
+ data={
+ CONF_API_KEY: api_key,
+ CONF_NAME: name,
+ CONF_FROM: self._from_stations[0].signature,
+ CONF_TO: self._to_stations[0].signature,
+ CONF_TIME: train_time,
+ CONF_WEEKDAY: train_days,
+ },
+ options={CONF_FILTER_PRODUCT: filter_product},
+ )
+ return self.async_create_entry(
+ title=name,
+ data={
+ CONF_API_KEY: api_key,
+ CONF_NAME: name,
+ CONF_FROM: self._from_stations[0].signature,
+ CONF_TO: self._to_stations[0].signature,
+ CONF_TIME: train_time,
+ CONF_WEEKDAY: train_days,
+ },
+ options={CONF_FILTER_PRODUCT: filter_product},
+ )
+ self._data = user_input
+ return await self.async_step_select_stations()
+
+ return self.async_show_form(
+ step_id="initial",
+ data_schema=self.add_suggested_values_to_schema(
+ DATA_SCHEMA, user_input or {}
+ ),
+ errors=errors,
+ )
+
+ async def async_step_select_stations(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the select station step."""
+ if user_input is not None:
+ api_key: str = self._data[CONF_API_KEY]
+ train_from: str = user_input[CONF_FROM]
+ train_to: str = user_input[CONF_TO]
+ train_time: str | None = self._data.get(CONF_TIME)
+ train_days: list = self._data[CONF_WEEKDAY]
+ filter_product: str | None = self._data[CONF_FILTER_PRODUCT]
+
+ if filter_product == "":
+ filter_product = None
+
+ name = f"{self._data[CONF_FROM]} to {self._data[CONF_TO]}"
+ if train_time:
+ name = (
+ f"{self._data[CONF_FROM]} to {self._data[CONF_TO]} at {train_time}"
)
- return self.async_create_entry(
+ self._async_abort_entries_match(
+ {
+ CONF_API_KEY: api_key,
+ CONF_FROM: train_from,
+ CONF_TO: user_input[CONF_TO],
+ CONF_TIME: train_time,
+ CONF_WEEKDAY: train_days,
+ CONF_FILTER_PRODUCT: filter_product,
+ }
+ )
+ if self.source == SOURCE_RECONFIGURE:
+ reconfigure_entry = self._get_reconfigure_entry()
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
title=name,
data={
CONF_API_KEY: api_key,
@@ -225,13 +282,45 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
},
options={CONF_FILTER_PRODUCT: filter_product},
)
+ return self.async_create_entry(
+ title=name,
+ data={
+ CONF_API_KEY: api_key,
+ CONF_NAME: name,
+ CONF_FROM: train_from,
+ CONF_TO: train_to,
+ CONF_TIME: train_time,
+ CONF_WEEKDAY: train_days,
+ },
+ options={CONF_FILTER_PRODUCT: filter_product},
+ )
+ from_options = [
+ SelectOptionDict(value=station.signature, label=station.station_name)
+ for station in self._from_stations
+ ]
+ to_options = [
+ SelectOptionDict(value=station.signature, label=station.station_name)
+ for station in self._to_stations
+ ]
+ schema = {}
+ if len(from_options) > 1:
+ schema[vol.Required(CONF_FROM)] = SelectSelector(
+ SelectSelectorConfig(
+ options=from_options, mode=SelectSelectorMode.DROPDOWN, sort=True
+ )
+ )
+ if len(to_options) > 1:
+ schema[vol.Required(CONF_TO)] = SelectSelector(
+ SelectSelectorConfig(
+ options=to_options, mode=SelectSelectorMode.DROPDOWN, sort=True
+ )
+ )
return self.async_show_form(
- step_id="user",
+ step_id="select_stations",
data_schema=self.add_suggested_values_to_schema(
- DATA_SCHEMA, user_input or {}
+ vol.Schema(schema), user_input or {}
),
- errors=errors,
)
diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py
index 49d4e1ded74..28c9ab6fe8e 100644
--- a/homeassistant/components/trafikverket_train/coordinator.py
+++ b/homeassistant/components/trafikverket_train/coordinator.py
@@ -7,15 +7,16 @@ from datetime import datetime, time, timedelta
import logging
from typing import TYPE_CHECKING
-from pytrafikverket import TrafikverketTrain
-from pytrafikverket.exceptions import (
+from pytrafikverket import (
InvalidAuthentication,
MultipleTrainStationsFound,
NoTrainAnnouncementFound,
NoTrainStationFound,
+ StationInfoModel,
+ TrafikverketTrain,
+ TrainStopModel,
UnknownError,
)
-from pytrafikverket.models import StationInfoModel, TrainStopModel
from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY
from homeassistant.core import HomeAssistant
@@ -74,31 +75,34 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]):
from_station: StationInfoModel
to_station: StationInfoModel
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: TVTrainConfigEntry) -> None:
"""Initialize the Trafikverket coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=TIME_BETWEEN_UPDATES,
)
self._train_api = TrafikverketTrain(
- async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY]
- )
- 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_get_clientsession(hass), config_entry.data[CONF_API_KEY]
)
+ self._time: time | None = dt_util.parse_time(config_entry.data[CONF_TIME])
+ self._weekdays: list[str] = config_entry.data[CONF_WEEKDAY]
+ self._filter_product: str | None = config_entry.options.get(CONF_FILTER_PRODUCT)
async def _async_setup(self) -> None:
"""Initiate stations."""
try:
- self.to_station = await self._train_api.async_search_train_station(
- self.config_entry.data[CONF_TO]
+ self.to_station = (
+ await self._train_api.async_get_train_station_from_signature(
+ self.config_entry.data[CONF_TO]
+ )
)
- self.from_station = await self._train_api.async_search_train_station(
- self.config_entry.data[CONF_FROM]
+ self.from_station = (
+ await self._train_api.async_get_train_station_from_signature(
+ self.config_entry.data[CONF_FROM]
+ )
)
except InvalidAuthentication as error:
raise ConfigEntryAuthFailed from error
diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py
index e5331a47d16..a4de8c1ef26 100644
--- a/homeassistant/components/trafikverket_train/sensor.py
+++ b/homeassistant/components/trafikverket_train/sensor.py
@@ -25,6 +25,8 @@ from .coordinator import TrainData, TVDataUpdateCoordinator
ATTR_PRODUCT_FILTER = "product_filter"
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class TrafikverketSensorEntityDescription(SensorEntityDescription):
diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json
index 89542211a92..02155e46c2f 100644
--- a/homeassistant/components/trafikverket_train/strings.json
+++ b/homeassistant/components/trafikverket_train/strings.json
@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -13,7 +14,7 @@
"incorrect_api_key": "Invalid API key for selected account"
},
"step": {
- "user": {
+ "initial": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"to": "To station",
@@ -27,6 +28,13 @@
"filter_product": "To filter by product description add the phrase here to match"
}
},
+ "select_stations": {
+ "description": "More than one station was found with the provided name, select the correct ones from the provided lists",
+ "data": {
+ "to": "To station",
+ "from": "From station"
+ }
+ },
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
@@ -38,10 +46,10 @@
"step": {
"init": {
"data": {
- "filter_product": "[%key:component::trafikverket_train::config::step::user::data::filter_product%]"
+ "filter_product": "[%key:component::trafikverket_train::config::step::initial::data::filter_product%]"
},
"data_description": {
- "filter_product": "[%key:component::trafikverket_train::config::step::user::data_description::filter_product%]"
+ "filter_product": "[%key:component::trafikverket_train::config::step::initial::data_description::filter_product%]"
}
}
}
diff --git a/homeassistant/components/trafikverket_weatherstation/__init__.py b/homeassistant/components/trafikverket_weatherstation/__init__.py
index 1bd7fc69ae4..8fe67b5c66a 100644
--- a/homeassistant/components/trafikverket_weatherstation/__init__.py
+++ b/homeassistant/components/trafikverket_weatherstation/__init__.py
@@ -14,7 +14,7 @@ TVWeatherConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) -> bool:
"""Set up Trafikverket Weatherstation from a config entry."""
- coordinator = TVDataUpdateCoordinator(hass)
+ coordinator = TVDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -22,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) ->
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) -> bool:
"""Unload Trafikverket Weatherstation config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py
index 28b9a124fc6..f4316b887b3 100644
--- a/homeassistant/components/trafikverket_weatherstation/config_flow.py
+++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py
@@ -15,8 +15,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py
index 22ecf6fc1b5..33f09c0ffe2 100644
--- a/homeassistant/components/trafikverket_weatherstation/coordinator.py
+++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py
@@ -34,18 +34,19 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfoModel]):
config_entry: TVWeatherConfigEntry
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: TVWeatherConfigEntry) -> None:
"""Initialize the Sensibo coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=TIME_BETWEEN_UPDATES,
)
self._weather_api = TrafikverketWeather(
- async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY]
+ async_get_clientsession(hass), config_entry.data[CONF_API_KEY]
)
- self._station = self.config_entry.data[CONF_STATION]
+ self._station = config_entry.data[CONF_STATION]
async def _async_update_data(self) -> WeatherStationInfoModel:
"""Fetch data from Trafikverket."""
diff --git a/homeassistant/components/trafikverket_weatherstation/diagnostics.py b/homeassistant/components/trafikverket_weatherstation/diagnostics.py
new file mode 100644
index 00000000000..e70d60493f6
--- /dev/null
+++ b/homeassistant/components/trafikverket_weatherstation/diagnostics.py
@@ -0,0 +1,17 @@
+"""Diagnostics support for Trafikverket Weatherstation."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import TVWeatherConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: TVWeatherConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for Trafikverket Weatherstation config entry."""
+ return asdict(entry.runtime_data.data)
diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py
index 22661426f00..bc17c82748a 100644
--- a/homeassistant/components/trafikverket_weatherstation/sensor.py
+++ b/homeassistant/components/trafikverket_weatherstation/sensor.py
@@ -42,6 +42,8 @@ PRECIPITATION_TYPE = [
"yes",
]
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class TrafikverketSensorEntityDescription(SensorEntityDescription):
diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json
index aabc5827a88..0fe1953d31e 100644
--- a/homeassistant/components/transmission/strings.json
+++ b/homeassistant/components/transmission/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "title": "Set up Transmission Client",
+ "title": "Set up Transmission client",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
@@ -96,7 +96,7 @@
"fields": {
"entry_id": {
"name": "Transmission entry",
- "description": "Config entry id."
+ "description": "ID of the config entry to use."
},
"torrent": {
"name": "Torrent",
diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py
index 5628274b967..49a11a57f65 100644
--- a/homeassistant/components/transport_nsw/sensor.py
+++ b/homeassistant/components/transport_nsw/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py
index fe4a6541d9e..8193c5a67dc 100644
--- a/homeassistant/components/travisci/sensor.py
+++ b/homeassistant/components/travisci/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py
index 9691ecf0744..e5ff5c64a8b 100644
--- a/homeassistant/components/trend/binary_sensor.py
+++ b/homeassistant/components/trend/binary_sensor.py
@@ -32,8 +32,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
-from homeassistant.helpers import device_registry as dr
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json
index 85012939fc1..16c7067c7ce 100644
--- a/homeassistant/components/trend/manifest.json
+++ b/homeassistant/components/trend/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "helper",
"iot_class": "calculated",
"quality_scale": "internal",
- "requirements": ["numpy==2.2.0"]
+ "requirements": ["numpy==2.2.2"]
}
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index e7d1091719b..6c7e521f3ef 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -21,7 +21,7 @@ from typing import Any, Final, TypedDict, final
from aiohttp import web
import mutagen
from mutagen.id3 import ID3, TextFrame as ID3Text
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components import ffmpeg, websocket_api
@@ -43,7 +43,7 @@ from homeassistant.const import (
)
from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import get_url
@@ -73,23 +73,23 @@ from .media_source import generate_media_source_id, media_source_id_to_kwargs
from .models import Voice
__all__ = [
+ "ATTR_AUDIO_OUTPUT",
+ "ATTR_PREFERRED_FORMAT",
+ "ATTR_PREFERRED_SAMPLE_BYTES",
+ "ATTR_PREFERRED_SAMPLE_CHANNELS",
+ "ATTR_PREFERRED_SAMPLE_RATE",
+ "CONF_LANG",
+ "DEFAULT_CACHE_DIR",
+ "PLATFORM_SCHEMA",
+ "PLATFORM_SCHEMA_BASE",
+ "Provider",
+ "SampleFormat",
+ "TtsAudioType",
+ "Voice",
"async_default_engine",
"async_get_media_source_audio",
"async_support_options",
- "ATTR_AUDIO_OUTPUT",
- "ATTR_PREFERRED_FORMAT",
- "ATTR_PREFERRED_SAMPLE_RATE",
- "ATTR_PREFERRED_SAMPLE_CHANNELS",
- "ATTR_PREFERRED_SAMPLE_BYTES",
- "CONF_LANG",
- "DEFAULT_CACHE_DIR",
"generate_media_source_id",
- "PLATFORM_SCHEMA_BASE",
- "PLATFORM_SCHEMA",
- "SampleFormat",
- "Provider",
- "TtsAudioType",
- "Voice",
]
_LOGGER = logging.getLogger(__name__)
@@ -1052,10 +1052,8 @@ class TextToSpeechUrlView(HomeAssistantView):
data = await request.json()
except ValueError:
return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST)
- if (
- not data.get("engine_id")
- and not data.get(ATTR_PLATFORM)
- or not data.get(ATTR_MESSAGE)
+ if (not data.get("engine_id") and not data.get(ATTR_PLATFORM)) or not data.get(
+ ATTR_MESSAGE
):
return self.json_message(
"Must specify platform and message", HTTPStatus.BAD_REQUEST
diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py
index 54ea89cb674..6f0541734d1 100644
--- a/homeassistant/components/tts/legacy.py
+++ b/homeassistant/components/tts/legacy.py
@@ -27,8 +27,7 @@ from homeassistant.const import (
CONF_PLATFORM,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.setup import (
diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py
index 429d46660e7..c4c1bb1ae15 100644
--- a/homeassistant/components/tts/notify.py
+++ b/homeassistant/components/tts/notify.py
@@ -13,7 +13,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME
from homeassistant.core import HomeAssistant, split_entity_id
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN
diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json
index b53e6fa27d8..96ee50a38c9 100644
--- a/homeassistant/components/tuya/manifest.json
+++ b/homeassistant/components/tuya/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "tuya",
"name": "Tuya",
- "codeowners": ["@Tuya", "@zlinoliver", "@frenck"],
+ "codeowners": ["@Tuya", "@zlinoliver"],
"config_flow": true,
"dependencies": ["ffmpeg"],
"dhcp": [
diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py
index 738492102a1..bab9ac309ec 100644
--- a/homeassistant/components/tuya/vacuum.py
+++ b/homeassistant/components/tuya/vacuum.py
@@ -89,9 +89,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
if self.find_dpcode(DPCode.PAUSE, prefer_function=True):
self._attr_supported_features |= VacuumEntityFeature.PAUSE
- if (
- self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True)
- or (
+ if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or (
+ (
enum_type := self.find_dpcode(
DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
)
diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py
index d163ae4e564..606fb4913d1 100644
--- a/homeassistant/components/twentemilieu/calendar.py
+++ b/homeassistant/components/twentemilieu/calendar.py
@@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import WASTE_TYPE_TO_DESCRIPTION
from .coordinator import TwenteMilieuConfigEntry
@@ -70,8 +70,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity):
waste_dates
and (
next_waste_pickup_date is None
- or waste_dates[0] # type: ignore[unreachable]
- < next_waste_pickup_date
+ or waste_dates[0] < next_waste_pickup_date
)
and waste_dates[0] >= dt_util.now().date()
):
diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py
index b54af031af3..7ed65bdd54b 100644
--- a/homeassistant/components/twilio/__init__.py
+++ b/homeassistant/components/twilio/__init__.py
@@ -8,8 +8,7 @@ from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py
index ab79ea9692d..4c432e0aeb5 100644
--- a/homeassistant/components/twilio_call/notify.py
+++ b/homeassistant/components/twilio_call/notify.py
@@ -15,7 +15,7 @@ from homeassistant.components.notify import (
)
from homeassistant.components.twilio import DATA_TWILIO
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py
index 531fadcf259..a3f824f375f 100644
--- a/homeassistant/components/twilio_sms/notify.py
+++ b/homeassistant/components/twilio_sms/notify.py
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
)
from homeassistant.components.twilio import DATA_TWILIO
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py
index aaad731d264..cd29ffaf423 100644
--- a/homeassistant/components/twinkly/__init__.py
+++ b/homeassistant/components/twinkly/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import TwinklyCoordinator
-PLATFORMS = [Platform.LIGHT]
+PLATFORMS = [Platform.LIGHT, Platform.SELECT]
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py
index 53ba8f084c3..0f2f87302af 100644
--- a/homeassistant/components/twinkly/config_flow.py
+++ b/homeassistant/components/twinkly/config_flow.py
@@ -9,10 +9,10 @@ from aiohttp import ClientError
from ttls.client import Twinkly
from voluptuous import Required, Schema
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN
@@ -58,7 +58,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery for twinkly."""
self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py
index 8a5e3e087ae..627fb0b39ba 100644
--- a/homeassistant/components/twinkly/coordinator.py
+++ b/homeassistant/components/twinkly/coordinator.py
@@ -27,6 +27,7 @@ class TwinklyData:
is_on: bool
movies: dict[int, str]
current_movie: int | None
+ current_mode: str | None
class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]):
@@ -66,6 +67,8 @@ class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]):
device_info = await self.client.get_details()
brightness = await self.client.get_brightness()
is_on = await self.client.is_on()
+ mode_data = await self.client.get_mode()
+ current_mode = mode_data.get("mode")
if self.supports_effects:
movies = (await self.client.get_saved_movies())["movies"]
except (TimeoutError, ClientError) as exception:
@@ -87,6 +90,7 @@ class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]):
is_on,
{movie["id"]: movie["name"] for movie in movies},
current_movie.get("id"),
+ current_mode,
)
def _async_update_device_info(self, name: str) -> None:
diff --git a/homeassistant/components/twinkly/entity.py b/homeassistant/components/twinkly/entity.py
new file mode 100644
index 00000000000..0a0f321bb17
--- /dev/null
+++ b/homeassistant/components/twinkly/entity.py
@@ -0,0 +1,27 @@
+"""Base entity for Twinkly."""
+
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DEV_MODEL, DEV_NAME, DOMAIN
+from .coordinator import TwinklyCoordinator
+
+
+class TwinklyEntity(CoordinatorEntity[TwinklyCoordinator]):
+ """Defines a base Twinkly entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: TwinklyCoordinator) -> None:
+ """Initialize."""
+ super().__init__(coordinator)
+ device_info = coordinator.data.device_info
+ mac = device_info["mac"]
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, mac)},
+ connections={(CONNECTION_NETWORK_MAC, mac)},
+ manufacturer="LEDWORKS",
+ model=device_info[DEV_MODEL],
+ name=device_info[DEV_NAME],
+ sw_version=coordinator.software_version,
+ )
diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py
index 1dfd6c1df30..31e95d70fc0 100644
--- a/homeassistant/components/twinkly/light.py
+++ b/homeassistant/components/twinkly/light.py
@@ -15,19 +15,11 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TwinklyConfigEntry, TwinklyCoordinator
-from .const import (
- DEV_LED_PROFILE,
- DEV_MODEL,
- DEV_NAME,
- DEV_PROFILE_RGB,
- DEV_PROFILE_RGBW,
- DOMAIN,
-)
+from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW
+from .entity import TwinklyEntity
_LOGGER = logging.getLogger(__name__)
@@ -43,10 +35,9 @@ async def async_setup_entry(
async_add_entities([entity], update_before_add=True)
-class TwinklyLight(CoordinatorEntity[TwinklyCoordinator], LightEntity):
+class TwinklyLight(TwinklyEntity, LightEntity):
"""Implementation of the light for the Twinkly service."""
- _attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "light"
@@ -54,7 +45,7 @@ class TwinklyLight(CoordinatorEntity[TwinklyCoordinator], LightEntity):
"""Initialize a TwinklyLight entity."""
super().__init__(coordinator)
device_info = coordinator.data.device_info
- self._attr_unique_id = mac = device_info["mac"]
+ self._attr_unique_id = device_info["mac"]
if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW:
self._attr_supported_color_modes = {ColorMode.RGBW}
@@ -68,14 +59,6 @@ class TwinklyLight(CoordinatorEntity[TwinklyCoordinator], LightEntity):
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._attr_color_mode = ColorMode.BRIGHTNESS
self.client = coordinator.client
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, mac)},
- connections={(CONNECTION_NETWORK_MAC, mac)},
- manufacturer="LEDWORKS",
- model=device_info[DEV_MODEL],
- name=device_info[DEV_NAME],
- sw_version=coordinator.software_version,
- )
if coordinator.supports_effects:
self._attr_supported_features = LightEntityFeature.EFFECT
self._update_attr()
@@ -116,9 +99,9 @@ class TwinklyLight(CoordinatorEntity[TwinklyCoordinator], LightEntity):
):
await self.client.interview()
if LightEntityFeature.EFFECT & self.supported_features:
- # Static color only supports rgb
await self.client.set_static_colour(
(
+ kwargs[ATTR_RGBW_COLOR][3],
kwargs[ATTR_RGBW_COLOR][0],
kwargs[ATTR_RGBW_COLOR][1],
kwargs[ATTR_RGBW_COLOR][2],
diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py
new file mode 100644
index 00000000000..38e5c9a6fc7
--- /dev/null
+++ b/homeassistant/components/twinkly/select.py
@@ -0,0 +1,49 @@
+"""The Twinkly select component."""
+
+from __future__ import annotations
+
+import logging
+
+from ttls.client import TWINKLY_MODES
+
+from homeassistant.components.select import SelectEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import TwinklyConfigEntry, TwinklyCoordinator
+from .entity import TwinklyEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: TwinklyConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up a mode select from a config entry."""
+ entity = TwinklyModeSelect(config_entry.runtime_data)
+ async_add_entities([entity], update_before_add=True)
+
+
+class TwinklyModeSelect(TwinklyEntity, SelectEntity):
+ """Twinkly Mode Selection."""
+
+ _attr_name = "Mode"
+ _attr_options = TWINKLY_MODES
+
+ def __init__(self, coordinator: TwinklyCoordinator) -> None:
+ """Initialize TwinklyModeSelect."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.data.device_info['mac']}_mode"
+ self.client = coordinator.client
+
+ @property
+ def current_option(self) -> str | None:
+ """Return current mode."""
+ return self.coordinator.data.current_mode
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.client.set_mode(option)
+ await self.coordinator.async_refresh()
diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py
index eef51ca9613..f94bcd54459 100644
--- a/homeassistant/components/twitter/notify.py
+++ b/homeassistant/components/twitter/notify.py
@@ -21,7 +21,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py
index 285a176af0a..7c50b69683f 100644
--- a/homeassistant/components/ubus/device_tracker.py
+++ b/homeassistant/components/ubus/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py
index a86f7a1cc83..b06d0e24891 100644
--- a/homeassistant/components/uk_transport/sensor.py
+++ b/homeassistant/components/uk_transport/sensor.py
@@ -17,11 +17,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MODE, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
-import homeassistant.util.dt as dt_util
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py
index 63c8533aa2e..3878e4c60eb 100644
--- a/homeassistant/components/unifi/config_flow.py
+++ b/homeassistant/components/unifi/config_flow.py
@@ -18,7 +18,6 @@ from urllib.parse import urlparse
from aiounifi.interfaces.sites import Sites
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryState,
@@ -34,8 +33,13 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MODEL_DESCRIPTION,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from . import UnifiConfigEntry
from .const import (
@@ -212,12 +216,12 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN):
return await self.async_step_user()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered UniFi device."""
parsed_url = urlparse(discovery_info.ssdp_location)
- model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION]
- mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL])
+ model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION]
+ mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL])
self.config = {
CONF_HOST: parsed_url.hostname,
diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py
index 735f76a73bf..da5ca74fc37 100644
--- a/homeassistant/components/unifi/device_tracker.py
+++ b/homeassistant/components/unifi/device_tracker.py
@@ -16,7 +16,7 @@ from aiounifi.models.api import ApiItemT
from aiounifi.models.client import Client
from aiounifi.models.device import Device
from aiounifi.models.event import Event, EventKey
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
@@ -24,10 +24,10 @@ from homeassistant.components.device_tracker import (
ScannerEntityDescription,
)
from homeassistant.core import Event as core_Event, HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.entity_registry as er
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import UnifiConfigEntry
from .const import DOMAIN as UNIFI_DOMAIN
diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py
index 782b026d6e4..b353ba6fc5c 100644
--- a/homeassistant/components/unifi/hub/entity_helper.py
+++ b/homeassistant/components/unifi/hub/entity_helper.py
@@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceSetPoePortModeRequest
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later, async_track_time_interval
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
class UnifiEntityHelper:
diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py
index f11ddefec98..64403152b0c 100644
--- a/homeassistant/components/unifi/hub/entity_loader.py
+++ b/homeassistant/components/unifi/hub/entity_loader.py
@@ -47,9 +47,13 @@ class UnifiEntityLoader:
hub.api.sites.update,
hub.api.system_information.update,
hub.api.traffic_rules.update,
+ hub.api.traffic_routes.update,
hub.api.wlans.update,
)
- self.polling_api_updaters = (hub.api.traffic_rules.update,)
+ self.polling_api_updaters = (
+ hub.api.traffic_rules.update,
+ hub.api.traffic_routes.update,
+ )
self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS]
self._dataUpdateCoordinator = DataUpdateCoordinator(
diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json
index 76990c1c4a1..6874bb5ae03 100644
--- a/homeassistant/components/unifi/icons.json
+++ b/homeassistant/components/unifi/icons.json
@@ -61,6 +61,9 @@
"traffic_rule_control": {
"default": "mdi:security-network"
},
+ "traffic_route_control": {
+ "default": "mdi:routes"
+ },
"poe_port_control": {
"default": "mdi:ethernet",
"state": {
diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py
index 1f54f56b194..f1ada9a01e0 100644
--- a/homeassistant/components/unifi/image.py
+++ b/homeassistant/components/unifi/image.py
@@ -17,7 +17,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import UnifiConfigEntry
from .entity import (
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index 194a8575174..fd78c606043 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -48,8 +48,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback
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 homeassistant.util import dt as dt_util, slugify
from . import UnifiConfigEntry
from .const import DEVICE_STATES
diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py
index ce726a0f5d0..fc63c092d56 100644
--- a/homeassistant/components/unifi/services.py
+++ b/homeassistant/components/unifi/services.py
@@ -69,8 +69,7 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) -
for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN):
if config_entry.state is not ConfigEntryState.LOADED or (
- (hub := config_entry.runtime_data)
- and not hub.available
+ ((hub := config_entry.runtime_data) and not hub.available)
or (client := hub.api.clients.get(mac)) is None
or client.is_wired
):
@@ -87,10 +86,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) ->
- Neither IP, hostname nor name is configured.
"""
for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN):
- if (
- config_entry.state is not ConfigEntryState.LOADED
- or (hub := config_entry.runtime_data)
- and not hub.available
+ if config_entry.state is not ConfigEntryState.LOADED or (
+ (hub := config_entry.runtime_data) and not hub.available
):
continue
diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json
index f9315318d1e..8f4f2b420a5 100644
--- a/homeassistant/components/unifi/strings.json
+++ b/homeassistant/components/unifi/strings.json
@@ -111,7 +111,7 @@
"fields": {
"device_id": {
"name": "[%key:common::config_flow::data::device%]",
- "description": "Try reconnect client to wireless network."
+ "description": "The device that should be forced to reconnect to the wireless network."
}
}
},
diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py
index 01843a8a95b..91e4a0222f6 100644
--- a/homeassistant/components/unifi/switch.py
+++ b/homeassistant/components/unifi/switch.py
@@ -20,6 +20,7 @@ from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups
from aiounifi.interfaces.outlets import Outlets
from aiounifi.interfaces.port_forwarding import PortForwarding
from aiounifi.interfaces.ports import Ports
+from aiounifi.interfaces.traffic_routes import TrafficRoutes
from aiounifi.interfaces.traffic_rules import TrafficRules
from aiounifi.interfaces.wlans import Wlans
from aiounifi.models.api import ApiItemT
@@ -31,6 +32,7 @@ from aiounifi.models.event import Event, EventKey
from aiounifi.models.outlet import Outlet
from aiounifi.models.port import Port
from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest
+from aiounifi.models.traffic_route import TrafficRoute, TrafficRouteSaveRequest
from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest
from aiounifi.models.wlan import Wlan, WlanEnableRequest
@@ -42,9 +44,9 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.helpers.entity_registry as er
from . import UnifiConfigEntry
from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN
@@ -170,6 +172,16 @@ async def async_traffic_rule_control_fn(
await hub.api.traffic_rules.update()
+async def async_traffic_route_control_fn(
+ hub: UnifiHub, obj_id: str, target: bool
+) -> None:
+ """Control traffic route state."""
+ traffic_route = hub.api.traffic_routes[obj_id].raw
+ await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target))
+ # Update the traffic routes so the UI is updated appropriately
+ await hub.api.traffic_routes.update()
+
+
async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None:
"""Control outlet relay."""
await hub.api.request(WlanEnableRequest.create(obj_id, target))
@@ -263,6 +275,19 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = (
object_fn=lambda api, obj_id: api.traffic_rules[obj_id],
unique_id_fn=lambda hub, obj_id: f"traffic_rule-{obj_id}",
),
+ UnifiSwitchEntityDescription[TrafficRoutes, TrafficRoute](
+ key="Traffic route control",
+ translation_key="traffic_route_control",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ api_handler_fn=lambda api: api.traffic_routes,
+ control_fn=async_traffic_route_control_fn,
+ device_info_fn=async_unifi_network_device_info_fn,
+ is_on_fn=lambda hub, traffic_route: traffic_route.enabled,
+ name_fn=lambda traffic_route: traffic_route.description,
+ object_fn=lambda api, obj_id: api.traffic_routes[obj_id],
+ unique_id_fn=lambda hub, obj_id: f"traffic_route-{obj_id}",
+ ),
UnifiSwitchEntityDescription[Ports, Port](
key="PoE port control",
translation_key="poe_port_control",
diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py
index d5e2e926114..1d7511aaae8 100644
--- a/homeassistant/components/unifi_direct/device_tracker.py
+++ b/homeassistant/components/unifi_direct/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/unifiled/light.py b/homeassistant/components/unifiled/light.py
index 4e1981875f4..dbc73177f21 100644
--- a/homeassistant/components/unifiled/light.py
+++ b/homeassistant/components/unifiled/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py
index ed409a6eea0..ba255bb7f7c 100644
--- a/homeassistant/components/unifiprotect/__init__.py
+++ b/homeassistant/components/unifiprotect/__init__.py
@@ -45,7 +45,12 @@ from .utils import (
async_create_api_client,
async_get_devices,
)
-from .views import ThumbnailProxyView, VideoEventProxyView, VideoProxyView
+from .views import (
+ SnapshotProxyView,
+ ThumbnailProxyView,
+ VideoEventProxyView,
+ VideoProxyView,
+)
_LOGGER = logging.getLogger(__name__)
@@ -173,6 +178,7 @@ async def _async_setup_entry(
data_service.async_setup()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.http.register_view(ThumbnailProxyView(hass))
+ hass.http.register_view(SnapshotProxyView(hass))
hass.http.register_view(VideoProxyView(hass))
hass.http.register_view(VideoEventProxyView(hass))
diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py
index 31950f8f7e4..22af2fb135d 100644
--- a/homeassistant/components/unifiprotect/config_flow.py
+++ b/homeassistant/components/unifiprotect/config_flow.py
@@ -14,7 +14,6 @@ from uiprotect.exceptions import ClientError, NotAuthorized
from unifi_discovery import async_console_is_alive
import voluptuous as vol
-from homeassistant.components import dhcp, ssdp
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigEntry,
@@ -36,6 +35,8 @@ from homeassistant.helpers.aiohttp_client import (
async_create_clientsession,
async_get_clientsession,
)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.loader import async_get_integration
@@ -107,14 +108,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
self._discovered_device: dict[str, str] = {}
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
_LOGGER.debug("Starting discovery via: %s", discovery_info)
return await self._async_discovery_handoff()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered UniFi device."""
_LOGGER.debug("Starting discovery via: %s", discovery_info)
diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py
index 335bc1e933d..90804559297 100644
--- a/homeassistant/components/unifiprotect/entity.py
+++ b/homeassistant/components/unifiprotect/entity.py
@@ -22,7 +22,7 @@ from uiprotect.data import (
)
from homeassistant.core import callback
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py
index 486a8956e0c..fcdfe5e85b8 100644
--- a/homeassistant/components/unifiprotect/light.py
+++ b/homeassistant/components/unifiprotect/light.py
@@ -7,7 +7,7 @@ from typing import Any
from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel
-from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
+from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -71,13 +71,10 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
- hass_brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
- unifi_brightness = hass_to_unifi_brightness(hass_brightness)
-
- _LOGGER.debug("Turning on light with brightness %s", unifi_brightness)
- await self.device.set_light(True, unifi_brightness)
+ _LOGGER.debug("Turning on light")
+ await self.device.api.set_light_is_led_force_on(self.device.id, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
_LOGGER.debug("Turning off light")
- await self.device.set_light(False)
+ await self.device.api.set_light_is_led_force_on(self.device.id, False)
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index 018a600f037..a4bb6d20841 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==7.4.1", "unifi-discovery==1.2.0"],
+ "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py
index 9bf6ed024f5..cc2e1c6a5fc 100644
--- a/homeassistant/components/unifiprotect/views.py
+++ b/homeassistant/components/unifiprotect/views.py
@@ -44,6 +44,34 @@ def async_generate_thumbnail_url(
return f"{url}?{urlencode(params)}"
+@callback
+def async_generate_snapshot_url(
+ nvr_id: str,
+ camera_id: str,
+ timestamp: datetime,
+ width: int | None = None,
+ height: int | None = None,
+) -> str:
+ """Generate URL for event thumbnail."""
+
+ url_format = SnapshotProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
+ url = url_format.format(
+ nvr_id=nvr_id,
+ camera_id=camera_id,
+ timestamp=timestamp.replace(microsecond=0).isoformat(),
+ )
+
+ params = {}
+ if width is not None:
+ params["width"] = str(width)
+ if height is not None:
+ params["height"] = str(height)
+
+ return f"{url}?{urlencode(params)}"
+
+
@callback
def async_generate_event_video_url(event: Event) -> str:
"""Generate URL for event video."""
@@ -188,6 +216,59 @@ class ThumbnailProxyView(ProtectProxyView):
return web.Response(body=thumbnail, content_type="image/jpeg")
+class SnapshotProxyView(ProtectProxyView):
+ """View to proxy snapshots at specified time from UniFi Protect."""
+
+ url = "/api/unifiprotect/snapshot/{nvr_id}/{camera_id}/{timestamp}"
+ name = "api:unifiprotect_snapshot"
+
+ async def get(
+ self, request: web.Request, nvr_id: str, camera_id: str, timestamp: str
+ ) -> web.Response:
+ """Get snapshot."""
+
+ data = self._get_data_or_404(nvr_id)
+ if isinstance(data, web.Response):
+ return data
+
+ camera = self._async_get_camera(data, camera_id)
+ if camera is None:
+ return _404(f"Invalid camera ID: {camera_id}")
+ if not camera.can_read_media(data.api.bootstrap.auth_user):
+ return _403(f"User cannot read media from camera: {camera.id}")
+
+ width: int | str | None = request.query.get("width")
+ height: int | str | None = request.query.get("height")
+
+ if width is not None:
+ try:
+ width = int(width)
+ except ValueError:
+ return _400("Invalid width param")
+ if height is not None:
+ try:
+ height = int(height)
+ except ValueError:
+ return _400("Invalid height param")
+
+ try:
+ timestamp_dt = datetime.fromisoformat(timestamp)
+ except ValueError:
+ return _400("Invalid timestamp")
+
+ try:
+ snapshot = await camera.get_snapshot(
+ width=width, height=height, dt=timestamp_dt
+ )
+ except ClientError as err:
+ return _404(err)
+
+ if snapshot is None:
+ return _404("snapshot not found")
+
+ return web.Response(body=snapshot, content_type="image/jpeg")
+
+
class VideoProxyView(ProtectProxyView):
"""View to proxy video clips from UniFi Protect."""
diff --git a/homeassistant/components/upb/const.py b/homeassistant/components/upb/const.py
index 16f2f1b7923..6e063c5a088 100644
--- a/homeassistant/components/upb/const.py
+++ b/homeassistant/components/upb/const.py
@@ -2,7 +2,7 @@
import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import VolDictType
DOMAIN = "upb"
diff --git a/homeassistant/components/upb/services.yaml b/homeassistant/components/upb/services.yaml
index cf415705d72..985ce11c436 100644
--- a/homeassistant/components/upb/services.yaml
+++ b/homeassistant/components/upb/services.yaml
@@ -49,7 +49,7 @@ link_deactivate:
target:
entity:
integration: upb
- domain: light
+ domain: scene
link_goto:
target:
diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py
index c279be78666..bdaf01518f1 100644
--- a/homeassistant/components/upc_connect/device_tracker.py
+++ b/homeassistant/components/upc_connect/device_tracker.py
@@ -15,8 +15,8 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py
index 30d7cacba8e..a3fec73dca8 100644
--- a/homeassistant/components/upcloud/__init__.py
+++ b/homeassistant/components/upcloud/__init__.py
@@ -2,14 +2,12 @@
from __future__ import annotations
-import dataclasses
from datetime import timedelta
import logging
import requests.exceptions
import upcloud_api
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
@@ -23,34 +21,21 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
-from .const import (
- CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE,
- DATA_UPCLOUD,
- DEFAULT_SCAN_INTERVAL,
-)
-from .coordinator import UpCloudDataUpdateCoordinator
+from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL
+from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH]
-@dataclasses.dataclass
-class UpCloudHassData:
- """Home Assistant UpCloud runtime data."""
-
- coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field(
- default_factory=dict
- )
-
-
-def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str:
+def _config_entry_update_signal_name(config_entry: UpCloudConfigEntry) -> str:
"""Get signal name for updates to a config entry."""
return CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE.format(config_entry.unique_id)
async def _async_signal_options_update(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: UpCloudConfigEntry
) -> None:
"""Signal config entry options update."""
async_dispatcher_send(
@@ -58,7 +43,7 @@ async def _async_signal_options_update(
)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool:
"""Set up the UpCloud config entry."""
manager = upcloud_api.CloudManager(
@@ -81,10 +66,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = UpCloudDataUpdateCoordinator(
hass,
+ config_entry=entry,
update_interval=update_interval,
cloud_manager=manager,
username=entry.data[CONF_USERNAME],
)
+ entry.runtime_data = coordinator
# Call the UpCloud API to refresh data
await coordinator.async_config_entry_first_refresh()
@@ -99,21 +86,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
- hass.data[DATA_UPCLOUD] = UpCloudHassData()
- hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator
-
# Forward entry setup
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool:
"""Unload the config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(
- config_entry, PLATFORMS
- )
-
- hass.data[DATA_UPCLOUD].coordinators.pop(config_entry.data[CONF_USERNAME])
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py
index f135eea24b1..bca313d306f 100644
--- a/homeassistant/components/upcloud/binary_sensor.py
+++ b/homeassistant/components/upcloud/binary_sensor.py
@@ -4,23 +4,21 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_UPCLOUD
+from .coordinator import UpCloudConfigEntry
from .entity import UpCloudServerEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: UpCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the UpCloud server binary sensor."""
- coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]]
- entities = [UpCloudBinarySensor(coordinator, uuid) for uuid in coordinator.data]
+ coordinator = config_entry.runtime_data
+ entities = [UpCloudBinarySensor(config_entry, uuid) for uuid in coordinator.data]
async_add_entities(entities, True)
diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py
index bb988726ba5..16adcc51ddf 100644
--- a/homeassistant/components/upcloud/config_flow.py
+++ b/homeassistant/components/upcloud/config_flow.py
@@ -9,16 +9,12 @@ import requests.exceptions
import upcloud_api
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
from homeassistant.core import callback
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+from .coordinator import UpCloudConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -92,7 +88,7 @@ class UpCloudConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: UpCloudConfigEntry,
) -> UpCloudOptionsFlow:
"""Get options flow."""
return UpCloudOptionsFlow()
diff --git a/homeassistant/components/upcloud/const.py b/homeassistant/components/upcloud/const.py
index a967a43c46e..763462c37f4 100644
--- a/homeassistant/components/upcloud/const.py
+++ b/homeassistant/components/upcloud/const.py
@@ -3,6 +3,5 @@
from datetime import timedelta
DOMAIN = "upcloud"
-DATA_UPCLOUD = "data_upcloud"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE = f"{DOMAIN}_config_entry_update:{{}}"
diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py
index e10128a30e4..8088b3a72ea 100644
--- a/homeassistant/components/upcloud/coordinator.py
+++ b/homeassistant/components/upcloud/coordinator.py
@@ -15,6 +15,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
+type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator]
+
+
class UpCloudDataUpdateCoordinator(
DataUpdateCoordinator[dict[str, upcloud_api.Server]]
):
@@ -24,17 +27,22 @@ class UpCloudDataUpdateCoordinator(
self,
hass: HomeAssistant,
*,
+ config_entry: UpCloudConfigEntry,
cloud_manager: upcloud_api.CloudManager,
update_interval: timedelta,
username: str,
) -> None:
"""Initialize coordinator."""
super().__init__(
- hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=f"{username}@UpCloud",
+ update_interval=update_interval,
)
self.cloud_manager = cloud_manager
- async def async_update_config(self, config_entry: ConfigEntry) -> None:
+ async def async_update_config(self, config_entry: UpCloudConfigEntry) -> None:
"""Handle config update."""
self.update_interval = timedelta(
seconds=config_entry.options[CONF_SCAN_INTERVAL]
diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py
index c64ca7be2ea..1ff5374bcaf 100644
--- a/homeassistant/components/upcloud/entity.py
+++ b/homeassistant/components/upcloud/entity.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import logging
from typing import Any
import upcloud_api
@@ -12,9 +11,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import UpCloudDataUpdateCoordinator
-
-_LOGGER = logging.getLogger(__name__)
+from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator
ATTR_CORE_NUMBER = "core_number"
ATTR_HOSTNAME = "hostname"
@@ -33,11 +30,12 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]):
def __init__(
self,
- coordinator: UpCloudDataUpdateCoordinator,
+ config_entry: UpCloudConfigEntry,
uuid: str,
) -> None:
"""Initialize the UpCloud server entity."""
- super().__init__(coordinator)
+ super().__init__(config_entry.runtime_data)
+ self.config_entry = config_entry
self.uuid = uuid
@property
@@ -95,13 +93,11 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]):
@property
def device_info(self) -> DeviceInfo:
"""Return info for device registry."""
- assert self.coordinator.config_entry is not None
+ assert self.config_entry is not None
return DeviceInfo(
configuration_url="https://hub.upcloud.com",
model="Control Panel",
entry_type=DeviceEntryType.SERVICE,
- identifiers={
- (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub")
- },
+ identifiers={(DOMAIN, f"{self.config_entry.data[CONF_USERNAME]}@hub")},
manufacturer="UpCloud Ltd",
)
diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py
index 7495357ca9e..97c08b19188 100644
--- a/homeassistant/components/upcloud/switch.py
+++ b/homeassistant/components/upcloud/switch.py
@@ -3,13 +3,12 @@
from typing import Any
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_USERNAME, STATE_OFF
+from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_UPCLOUD
+from .coordinator import UpCloudConfigEntry
from .entity import UpCloudServerEntity
SIGNAL_UPDATE_UPCLOUD = "upcloud_update"
@@ -17,12 +16,12 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update"
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: UpCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the UpCloud server switch."""
- coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]]
- entities = [UpCloudSwitch(coordinator, uuid) for uuid in coordinator.data]
+ coordinator = config_entry.runtime_data
+ entities = [UpCloudSwitch(config_entry, uuid) for uuid in coordinator.data]
async_add_entities(entities, True)
diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py
index 8ef9f44237f..0ff8c448197 100644
--- a/homeassistant/components/update/__init__.py
+++ b/homeassistant/components/update/__init__.py
@@ -9,7 +9,7 @@ import logging
from typing import Any, Final, final
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -68,8 +68,8 @@ __all__ = [
"ATTR_VERSION",
"DEVICE_CLASSES_SCHEMA",
"DOMAIN",
- "PLATFORM_SCHEMA_BASE",
"PLATFORM_SCHEMA",
+ "PLATFORM_SCHEMA_BASE",
"SERVICE_INSTALL",
"SERVICE_SKIP",
"UpdateDeviceClass",
diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml
index 036af10150a..45e30fee09e 100644
--- a/homeassistant/components/update/services.yaml
+++ b/homeassistant/components/update/services.yaml
@@ -9,6 +9,9 @@ install:
selector:
text:
backup:
+ filter:
+ supported_features:
+ - update.UpdateEntityFeature.BACKUP
required: false
selector:
boolean:
diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py
index 214521ee9c0..aacb7538b61 100644
--- a/homeassistant/components/upnp/__init__.py
+++ b/homeassistant/components/upnp/__init__.py
@@ -13,6 +13,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
CONFIG_ENTRY_FORCE_POLL,
@@ -49,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool
# Register device discovered-callback.
device_discovered_event = asyncio.Event()
- discovery_info: ssdp.SsdpServiceInfo | None = None
+ discovery_info: SsdpServiceInfo | None = None
async def device_discovered(
- headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
+ headers: SsdpServiceInfo, change: ssdp.SsdpChange
) -> None:
if change == ssdp.SsdpChange.BYEBYE:
return
diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py
index 41e481fa58c..95fd1ff0ea5 100644
--- a/homeassistant/components/upnp/config_flow.py
+++ b/homeassistant/components/upnp/config_flow.py
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
import voluptuous as vol
from homeassistant.components import ssdp
-from homeassistant.components.ssdp import SsdpServiceInfo
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigEntry,
@@ -18,6 +17,12 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MODEL_NAME,
+ SsdpServiceInfo,
+)
from .const import (
CONFIG_ENTRY_FORCE_POLL,
@@ -37,17 +42,17 @@ from .const import (
from .device import async_get_mac_address_from_host, get_preferred_location
-def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str:
+def _friendly_name_from_discovery(discovery_info: SsdpServiceInfo) -> str:
"""Extract user-friendly name from discovery."""
return cast(
str,
- discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
- or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)
+ discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
+ or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME)
or discovery_info.ssdp_headers.get("_host", ""),
)
-def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool:
+def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool:
"""Test if discovery is complete and usable."""
return bool(
discovery_info.ssdp_udn
@@ -59,7 +64,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool:
async def _async_discovered_igd_devices(
hass: HomeAssistant,
-) -> list[ssdp.SsdpServiceInfo]:
+) -> list[SsdpServiceInfo]:
"""Discovery IGD devices."""
return await ssdp.async_get_discovery_info_by_st(
hass, ST_IGD_V1
@@ -76,10 +81,10 @@ async def _async_mac_address_from_discovery(
return await async_get_mac_address_from_host(hass, host)
-def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
+def _is_igd_device(discovery_info: SsdpServiceInfo) -> bool:
"""Test if discovery is a complete IGD device."""
root_device_info = discovery_info.upnp
- return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2}
+ return root_device_info.get(ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2}
class UpnpFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -167,7 +172,7 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered UPnP/IGD device.
diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json
index 08e0be2d712..df4daa8782c 100644
--- a/homeassistant/components/upnp/manifest.json
+++ b/homeassistant/components/upnp/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["async_upnp_client"],
- "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"],
+ "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py
index 266542de9d6..25917d09096 100644
--- a/homeassistant/components/uptime/sensor.py
+++ b/homeassistant/components/uptime/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py
index 2da72d16ac6..d68742522a0 100644
--- a/homeassistant/components/usb/__init__.py
+++ b/homeassistant/components/usb/__init__.py
@@ -2,14 +2,18 @@
from __future__ import annotations
-from collections.abc import Coroutine
+import asyncio
+from collections.abc import Callable, Coroutine, Sequence
import dataclasses
+from datetime import datetime, timedelta
import fnmatch
+from functools import partial
import logging
import os
import sys
-from typing import TYPE_CHECKING, Any
+from typing import Any, overload
+from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError
from serial.tools.list_ports import comports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
@@ -24,9 +28,16 @@ from homeassistant.core import (
HomeAssistant,
callback as hass_callback,
)
-from homeassistant.data_entry_flow import BaseServiceInfo
-from homeassistant.helpers import config_validation as cv, discovery_flow, system_info
+from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.debounce import Debouncer
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstant,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import USBMatcher, async_get_usb
@@ -34,18 +45,19 @@ from .const import DOMAIN
from .models import USBDevice
from .utils import usb_device_from_port
-if TYPE_CHECKING:
- from pyudev import Device, MonitorObserver
-
_LOGGER = logging.getLogger(__name__)
-REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown
+PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None]
+
+POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5)
+REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown
+ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register
__all__ = [
- "async_is_plugged_in",
- "async_register_scan_request_callback",
"USBCallbackMatcher",
- "UsbServiceInfo",
+ "async_is_plugged_in",
+ "async_register_port_event_callback",
+ "async_register_scan_request_callback",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -76,6 +88,15 @@ def async_register_initial_scan_callback(
return discovery.async_register_initial_scan_callback(callback)
+@hass_callback
+def async_register_port_event_callback(
+ hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE
+) -> CALLBACK_TYPE:
+ """Register to receive a callback when a USB device is connected or disconnected."""
+ discovery: USBDiscovery = hass.data[DOMAIN]
+ return discovery.async_register_port_event_callback(callback)
+
+
@hass_callback
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
"""Return True is a USB device is present."""
@@ -99,23 +120,36 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo
usb_discovery: USBDiscovery = hass.data[DOMAIN]
return any(
- _is_matching(USBDevice(*device_tuple), matcher)
- for device_tuple in usb_discovery.seen
+ _is_matching(
+ USBDevice(
+ device=device,
+ vid=vid,
+ pid=pid,
+ serial_number=serial_number,
+ manufacturer=manufacturer,
+ description=description,
+ ),
+ matcher,
+ )
+ for (
+ device,
+ vid,
+ pid,
+ serial_number,
+ manufacturer,
+ description,
+ ) in usb_discovery.seen
)
-@dataclasses.dataclass(slots=True)
-class UsbServiceInfo(BaseServiceInfo):
- """Prepared info from usb entries."""
-
- device: str
- vid: str
- pid: str
- serial_number: str | None
- manufacturer: str | None
- description: str | None
+_DEPRECATED_UsbServiceInfo = DeprecatedConstant(
+ _UsbServiceInfo,
+ "homeassistant.helpers.service_info.usb.UsbServiceInfo",
+ "2026.2",
+)
+@overload
def human_readable_device_name(
device: str,
serial_number: str | None,
@@ -123,11 +157,32 @@ def human_readable_device_name(
description: str | None,
vid: str | None,
pid: str | None,
+) -> str: ...
+
+
+@overload
+def human_readable_device_name(
+ device: str,
+ serial_number: str | None,
+ manufacturer: str | None,
+ description: str | None,
+ vid: int | None,
+ pid: int | None,
+) -> str: ...
+
+
+def human_readable_device_name(
+ device: str,
+ serial_number: str | None,
+ manufacturer: str | None,
+ description: str | None,
+ vid: str | int | None,
+ pid: str | int | None,
) -> str:
"""Return a human readable name from USBDevice attributes."""
device_details = f"{device}, s/n: {serial_number or 'n/a'}"
manufacturer_details = f" - {manufacturer}" if manufacturer else ""
- vendor_details = f" - {vid}:{pid}" if vid else ""
+ vendor_details = f" - {vid}:{pid}" if vid is not None else ""
full_details = f"{device_details}{manufacturer_details}{vendor_details}"
if not description:
@@ -200,13 +255,19 @@ class USBDiscovery:
self.seen: set[tuple[str, ...]] = set()
self.observer_active = False
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
+ self._add_remove_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
self._request_callbacks: list[CALLBACK_TYPE] = []
self.initial_scan_done = False
self._initial_scan_callbacks: list[CALLBACK_TYPE] = []
+ self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set()
+ self._last_processed_devices: set[USBDevice] = set()
+ self._scan_lock = asyncio.Lock()
async def async_setup(self) -> None:
"""Set up USB Discovery."""
- await self._async_start_monitor()
+ if self._async_supports_monitoring():
+ await self._async_start_monitor()
+
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
@@ -220,69 +281,60 @@ class USBDiscovery:
if self._request_debouncer:
self._request_debouncer.async_shutdown()
+ @hass_callback
+ def _async_supports_monitoring(self) -> bool:
+ return sys.platform == "linux"
+
async def _async_start_monitor(self) -> None:
- """Start monitoring hardware with pyudev."""
- if not sys.platform.startswith("linux"):
- return
- info = await system_info.async_get_system_info(self.hass)
- if info.get("docker"):
- return
-
- if not (
- observer := await self.hass.async_add_executor_job(
- self._get_monitor_observer
+ """Start monitoring hardware."""
+ try:
+ await self._async_start_aiousbwatcher()
+ except InotifyNotAvailableError as ex:
+ _LOGGER.info(
+ "Falling back to periodic filesystem polling for development, aiousbwatcher "
+ "is not available on this system: %s",
+ ex,
)
- ):
- return
+ self._async_start_monitor_polling()
- def _stop_observer(event: Event) -> None:
- observer.stop()
+ @hass_callback
+ def _async_start_monitor_polling(self) -> None:
+ """Start monitoring hardware with polling (for development only!)."""
- self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer)
- self.observer_active = True
+ async def _scan(event_time: datetime) -> None:
+ await self._async_scan_serial()
- def _get_monitor_observer(self) -> MonitorObserver | None:
- """Get the monitor observer.
+ stop_callback = async_track_time_interval(
+ self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD
+ )
- This runs in the executor because the import
- does blocking I/O.
+ @hass_callback
+ def _stop_polling(event: Event) -> None:
+ stop_callback()
+
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling)
+
+ async def _async_start_aiousbwatcher(self) -> None:
+ """Start monitoring hardware with aiousbwatcher.
+
+ Returns True if successful.
"""
- from pyudev import ( # pylint: disable=import-outside-toplevel
- Context,
- Monitor,
- MonitorObserver,
- )
- try:
- context = Context()
- except (ImportError, OSError):
- return None
+ @hass_callback
+ def _usb_change_callback() -> None:
+ self._async_delayed_add_remove_scan()
- monitor = Monitor.from_netlink(context)
- try:
- monitor.filter_by(subsystem="tty")
- except ValueError as ex: # this fails on WSL
- _LOGGER.debug(
- "Unable to setup pyudev filtering; This is expected on WSL: %s", ex
- )
- return None
+ watcher = AIOUSBWatcher()
+ watcher.async_register_callback(_usb_change_callback)
+ cancel = watcher.async_start()
- observer = MonitorObserver(
- monitor, callback=self._device_discovered, name="usb-observer"
- )
+ @hass_callback
+ def _async_stop_watcher(event: Event) -> None:
+ cancel()
- observer.start()
- return observer
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_watcher)
- def _device_discovered(self, device: Device) -> None:
- """Call when the observer discovers a new usb tty device."""
- if device.action != "add":
- return
- _LOGGER.debug(
- "Discovered Device at path: %s, triggering scan serial",
- device.device_path,
- )
- self.hass.create_task(self._async_scan())
+ self.observer_active = True
@hass_callback
def async_register_scan_request_callback(
@@ -318,6 +370,20 @@ class USBDiscovery:
return _async_remove_callback
+ @hass_callback
+ def async_register_port_event_callback(
+ self,
+ callback: PORT_EVENT_CALLBACK_TYPE,
+ ) -> CALLBACK_TYPE:
+ """Register a port event callback."""
+ self._port_event_callbacks.add(callback)
+
+ @hass_callback
+ def _async_remove_callback() -> None:
+ self._port_event_callbacks.discard(callback)
+
+ return _async_remove_callback
+
async def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
"""Process a USB discovery."""
_LOGGER.debug("Discovered USB Device: %s", device)
@@ -330,7 +396,7 @@ class USBDiscovery:
if not matched:
return
- service_info: UsbServiceInfo | None = None
+ service_info: _UsbServiceInfo | None = None
sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item))
most_matched_fields = len(sorted_by_most_targeted[0])
@@ -342,7 +408,7 @@ class USBDiscovery:
break
if service_info is None:
- service_info = UsbServiceInfo(
+ service_info = _UsbServiceInfo(
device=await self.hass.async_add_executor_job(
get_serial_by_id, device.device
),
@@ -360,13 +426,15 @@ class USBDiscovery:
service_info,
)
- async def _async_process_ports(self, ports: list[ListPortInfo]) -> None:
+ async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None:
"""Process each discovered port."""
- usb_devices = [
+ _LOGGER.debug("Processing ports: %r", ports)
+ usb_devices = {
usb_device_from_port(port)
for port in ports
if port.vid is not None or port.pid is not None
- ]
+ }
+ _LOGGER.debug("USB devices: %r", usb_devices)
# CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and
# `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them.
@@ -377,7 +445,7 @@ class USBDiscovery:
if dev.device.startswith("/dev/cu.SLAB_USBtoUART")
}
- usb_devices = [
+ usb_devices = {
dev
for dev in usb_devices
if dev.serial_number not in silabs_serials
@@ -385,16 +453,47 @@ class USBDiscovery:
dev.serial_number in silabs_serials
and dev.device.startswith("/dev/cu.SLAB_USBtoUART")
)
- ]
+ }
+
+ added_devices = usb_devices - self._last_processed_devices
+ removed_devices = self._last_processed_devices - usb_devices
+ self._last_processed_devices = usb_devices
+
+ _LOGGER.debug(
+ "Added devices: %r, removed devices: %r", added_devices, removed_devices
+ )
+
+ if added_devices or removed_devices:
+ for callback in self._port_event_callbacks.copy():
+ try:
+ callback(added_devices, removed_devices)
+ except Exception:
+ _LOGGER.exception("Error in USB port event callback")
for usb_device in usb_devices:
await self._async_process_discovered_usb_device(usb_device)
+ @hass_callback
+ def _async_delayed_add_remove_scan(self) -> None:
+ """Request a serial scan after a debouncer delay."""
+ if not self._add_remove_debouncer:
+ self._add_remove_debouncer = Debouncer(
+ self.hass,
+ _LOGGER,
+ cooldown=ADD_REMOVE_SCAN_COOLDOWN,
+ immediate=False,
+ function=self._async_scan,
+ background=True,
+ )
+ self._add_remove_debouncer.async_schedule_call()
+
async def _async_scan_serial(self) -> None:
"""Scan serial ports."""
- await self._async_process_ports(
- await self.hass.async_add_executor_job(comports)
- )
+ _LOGGER.debug("Executing comports scan")
+ async with self._scan_lock:
+ await self._async_process_ports(
+ await self.hass.async_add_executor_job(comports)
+ )
if self.initial_scan_done:
return
@@ -435,3 +534,11 @@ async def websocket_usb_scan(
if not usb_discovery.observer_active:
await usb_discovery.async_request_scan()
connection.send_result(msg["id"])
+
+
+# These can be removed if no deprecated constant are in this module anymore
+__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
+__dir__ = partial(
+ dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
+)
+__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json
index 19269801c11..7035e2ab2cb 100644
--- a/homeassistant/components/usb/manifest.json
+++ b/homeassistant/components/usb/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["pyudev==0.24.1", "pyserial==3.5"]
+ "requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"]
}
diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py
index efc5b11c26e..11eccd9cd9b 100644
--- a/homeassistant/components/usb/models.py
+++ b/homeassistant/components/usb/models.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
-@dataclass
+@dataclass(slots=True, frozen=True, kw_only=True)
class USBDevice:
"""A usb device."""
diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py
index aa9817eab7d..3dd380e79a8 100644
--- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py
+++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py
@@ -27,8 +27,7 @@ from homeassistant.const import (
UnitOfLength,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import aiohttp_client
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py
index aac31e085a0..e2b3411c193 100644
--- a/homeassistant/components/utility_meter/__init__.py
+++ b/homeassistant/components/utility_meter/__init__.py
@@ -11,8 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant, split_entity_id
-from homeassistant.helpers import discovery, entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ discovery,
+ entity_registry as er,
+)
from homeassistant.helpers.device import (
async_remove_stale_devices_links_keep_entity_device,
)
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index 9c13aa1984a..cd65c42b22a 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -49,8 +49,7 @@ from homeassistant.helpers.event import (
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.template import is_number
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.enum import try_parse_enum
from .const import (
diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py
index a6f0202ee25..0e09408551d 100644
--- a/homeassistant/components/uvc/camera.py
+++ b/homeassistant/components/uvc/camera.py
@@ -20,7 +20,7 @@ from homeassistant.components.camera import (
from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import utc_from_timestamp
diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py
index 6fe2c3e2a5b..3b1eee8509c 100644
--- a/homeassistant/components/vacuum/__init__.py
+++ b/homeassistant/components/vacuum/__init__.py
@@ -9,7 +9,7 @@ from functools import partial
import logging
from typing import TYPE_CHECKING, Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -376,7 +376,7 @@ class StateVacuumEntity(
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
- if type(features) is int: # noqa: E721
+ if type(features) is int:
new_features = VacuumEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py
index 82c00a57b5e..0ae03d9219e 100644
--- a/homeassistant/components/vacuum/device_action.py
+++ b/homeassistant/components/vacuum/device_action.py
@@ -13,8 +13,7 @@ from homeassistant.const import (
CONF_TYPE,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START
diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py
index 48f659103e1..424ffdc0ed2 100644
--- a/homeassistant/components/vasttrafik/sensor.py
+++ b/homeassistant/components/vasttrafik/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_DELAY, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index ad1c35a124b..41b8730eeb0 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -58,6 +58,21 @@ async def velbus_scan_task(
raise PlatformNotReady(
f"Connection error while connecting to Velbus {entry_id}: {ex}"
) from ex
+ # create all modules
+ dev_reg = dr.async_get(hass)
+ for module in controller.get_modules().values():
+ dev_reg.async_get_or_create(
+ config_entry_id=entry_id,
+ identifiers={
+ (DOMAIN, str(module.get_addresses()[0])),
+ },
+ manufacturer="Velleman",
+ model=module.get_type_name(),
+ model_id=str(module.get_type()),
+ name=f"{module.get_name()} ({module.get_type_name()})",
+ sw_version=module.get_sw_version(),
+ serial_number=module.get_serial(),
+ )
def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None:
diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py
index 26e2fafabbc..9e99b2631d4 100644
--- a/homeassistant/components/velbus/config_flow.py
+++ b/homeassistant/components/velbus/config_flow.py
@@ -8,9 +8,9 @@ import velbusaio.controller
from velbusaio.exceptions import VelbusConnectionFailed
import voluptuous as vol
-from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME, CONF_PORT
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.util import slugify
from .const import DOMAIN
@@ -69,9 +69,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
errors=self._errors,
)
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB Discovery."""
await self.async_set_unique_id(
f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}"
diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py
index 2d9f6e98a4c..b40f64e8607 100644
--- a/homeassistant/components/velbus/const.py
+++ b/homeassistant/components/velbus/const.py
@@ -11,6 +11,7 @@ from homeassistant.components.climate import (
DOMAIN: Final = "velbus"
+CONF_CONFIG_ENTRY: Final = "config_entry"
CONF_INTERFACE: Final = "interface"
CONF_MEMO_TEXT: Final = "memo_text"
diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py
index 75b7669edec..5001ac80ab3 100644
--- a/homeassistant/components/velbus/diagnostics.py
+++ b/homeassistant/components/velbus/diagnostics.py
@@ -8,7 +8,6 @@ from velbusaio.channels import Channel as VelbusChannel
from velbusaio.module import Module as VelbusModule
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceEntry
from . import VelbusConfigEntry
@@ -20,28 +19,18 @@ async def async_get_config_entry_diagnostics(
controller = entry.runtime_data.controller
data: dict[str, Any] = {"entry": entry.as_dict(), "modules": []}
for module in controller.get_modules().values():
- data["modules"].append(_build_module_diagnostics_info(module))
+ data["modules"].append(await _build_module_diagnostics_info(module))
return data
-async def async_get_device_diagnostics(
- hass: HomeAssistant, entry: VelbusConfigEntry, device: DeviceEntry
-) -> dict[str, Any]:
- """Return diagnostics for a device entry."""
- controller = entry.runtime_data.controller
- channel = list(next(iter(device.identifiers)))[1]
- modules = controller.get_modules()
- return _build_module_diagnostics_info(modules[int(channel)])
-
-
-def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]:
+async def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]:
"""Build per module diagnostics info."""
data: dict[str, Any] = {
"type": module.get_type_name(),
"address": module.get_addresses(),
"name": module.get_name(),
"sw_version": module.get_sw_version(),
- "is_loaded": module.is_loaded(),
+ "is_loaded": await module.is_loaded(),
"channels": _build_channels_diagnostics_info(module.get_channels()),
}
return data
diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py
index 65f8a1d8d31..07dac78b6f1 100644
--- a/homeassistant/components/velbus/entity.py
+++ b/homeassistant/components/velbus/entity.py
@@ -14,32 +14,57 @@ from homeassistant.helpers.entity import Entity
from .const import DOMAIN
+# device identifiers for modules
+# (DOMAIN, module_address)
+
+# device identifiers for channels that are subdevices of a module
+# (DOMAIN, f"{module_address}-{channel_number}")
+
class VelbusEntity(Entity):
"""Representation of a Velbus entity."""
+ _attr_has_entity_name = True
_attr_should_poll: bool = False
def __init__(self, channel: VelbusChannel) -> None:
"""Initialize a Velbus entity."""
self._channel = channel
+ self._module_adress = str(channel.get_module_address())
self._attr_name = channel.get_name()
self._attr_device_info = DeviceInfo(
identifiers={
- (DOMAIN, str(channel.get_module_address())),
+ (DOMAIN, self._get_identifier()),
},
manufacturer="Velleman",
model=channel.get_module_type_name(),
+ model_id=str(channel.get_module_type()),
name=channel.get_full_name(),
sw_version=channel.get_module_sw_version(),
+ serial_number=channel.get_module_serial(),
)
- serial = channel.get_module_serial() or str(channel.get_module_address())
+ if self._channel.is_sub_device():
+ self._attr_device_info["via_device"] = (
+ DOMAIN,
+ self._module_adress,
+ )
+ serial = channel.get_module_serial() or self._module_adress
self._attr_unique_id = f"{serial}-{channel.get_channel_number()}"
+ def _get_identifier(self) -> str:
+ """Return the identifier of the entity."""
+ if not self._channel.is_sub_device():
+ return self._module_adress
+ return f"{self._module_adress}-{self._channel.get_channel_number()}"
+
async def async_added_to_hass(self) -> None:
"""Add listener for state changes."""
self._channel.on_status_update(self._on_update)
+ async def async_will_remove_from_hass(self) -> None:
+ """Remove listener for state changes."""
+ self._channel.remove_on_status_update(self._on_update)
+
async def _on_update(self) -> None:
self.async_write_ha_state()
diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py
index 1adf52a8198..c134095c2ff 100644
--- a/homeassistant/components/velbus/light.py
+++ b/homeassistant/components/velbus/light.py
@@ -122,19 +122,14 @@ class VelbusButtonLight(VelbusEntity, LightEntity):
@api_call
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the Velbus light to turn on."""
- if ATTR_FLASH in kwargs:
- if kwargs[ATTR_FLASH] == FLASH_LONG:
- attr, *args = "set_led_state", "slow"
- elif kwargs[ATTR_FLASH] == FLASH_SHORT:
- attr, *args = "set_led_state", "fast"
- else:
- attr, *args = "set_led_state", "on"
+ if (flash := ATTR_FLASH in kwargs) and kwargs[ATTR_FLASH] == FLASH_LONG:
+ await self._channel.set_led_state("slow")
+ elif flash and kwargs[ATTR_FLASH] == FLASH_SHORT:
+ await self._channel.set_led_state("fast")
else:
- attr, *args = "set_led_state", "on"
- await getattr(self._channel, attr)(*args)
+ await self._channel.set_led_state("on")
@api_call
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the velbus light to turn off."""
- attr, *args = "set_led_state", "off"
- await getattr(self._channel, attr)(*args)
+ await self._channel.set_led_state("off")
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index 90981c426f9..960f127d16e 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.12.2"],
+ "requirements": ["velbus-aio==2025.1.1"],
"usb": [
{
"vid": "10CF",
diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml
index 477b6768e71..0ad3e3ce485 100644
--- a/homeassistant/components/velbus/quality_scale.yaml
+++ b/homeassistant/components/velbus/quality_scale.yaml
@@ -17,16 +17,13 @@ rules:
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
- entity-event-setup: todo
+ entity-event-setup: done
entity-unique-id: done
- has-entity-name: todo
+ has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
- unique-config-entry:
- status: todo
- comment: |
- Manual step does not generate an unique-id
+ unique-config-entry: done
# Silver
action-exceptions: todo
@@ -41,7 +38,7 @@ rules:
status: exempt
comment: |
This integration does not require authentication.
- test-coverage: todo
+ test-coverage: done
# Gold
devices: done
diagnostics: done
diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py
index 3f0b1bd6cdb..765c5a0f674 100644
--- a/homeassistant/components/velbus/services.py
+++ b/homeassistant/components/velbus/services.py
@@ -9,15 +9,19 @@ from typing import TYPE_CHECKING
import voluptuous as vol
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_validation as cv
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import config_validation as cv, selector
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.storage import STORAGE_DIR
if TYPE_CHECKING:
from . import VelbusConfigEntry
from .const import (
+ CONF_CONFIG_ENTRY,
CONF_INTERFACE,
CONF_MEMO_TEXT,
DOMAIN,
@@ -32,6 +36,7 @@ def setup_services(hass: HomeAssistant) -> None:
"""Register the velbus services."""
def check_entry_id(interface: str) -> str:
+ """Check the config_entry for a specific interface."""
for config_entry in hass.config_entries.async_entries(DOMAIN):
if "port" in config_entry.data and config_entry.data["port"] == interface:
return config_entry.entry_id
@@ -39,51 +44,71 @@ def setup_services(hass: HomeAssistant) -> None:
"The interface provided is not defined as a port in a Velbus integration"
)
- def get_config_entry(interface: str) -> VelbusConfigEntry | None:
- for config_entry in hass.config_entries.async_entries(DOMAIN):
- if "port" in config_entry.data and config_entry.data["port"] == interface:
- return config_entry
- return None
+ async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry:
+ """Get the config entry for this service call."""
+ if CONF_CONFIG_ENTRY in call.data:
+ entry_id = call.data[CONF_CONFIG_ENTRY]
+ elif CONF_INTERFACE in call.data:
+ # Deprecated in 2025.2, to remove in 2025.8
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_interface_parameter",
+ breaks_in_ha_version="2025.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_interface_parameter",
+ )
+ entry_id = call.data[CONF_INTERFACE]
+ if not (entry := hass.config_entries.async_get_entry(entry_id)):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="integration_not_found",
+ translation_placeholders={"target": DOMAIN},
+ )
+ if entry.state is not ConfigEntryState.LOADED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="not_loaded",
+ translation_placeholders={"target": entry.title},
+ )
+ return entry
async def scan(call: ServiceCall) -> None:
"""Handle a scan service call."""
- entry = get_config_entry(call.data[CONF_INTERFACE])
- if entry:
- await entry.runtime_data.controller.scan()
+ entry = await get_config_entry(call)
+ await entry.runtime_data.controller.scan()
async def syn_clock(call: ServiceCall) -> None:
"""Handle a sync clock service call."""
- entry = get_config_entry(call.data[CONF_INTERFACE])
- if entry:
- await entry.runtime_data.controller.sync_clock()
+ entry = await get_config_entry(call)
+ await entry.runtime_data.controller.sync_clock()
async def set_memo_text(call: ServiceCall) -> None:
"""Handle Memo Text service call."""
- entry = get_config_entry(call.data[CONF_INTERFACE])
- if entry:
- memo_text = call.data[CONF_MEMO_TEXT]
- module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS])
- if module:
- await module.set_memo_text(memo_text.async_render())
+ entry = await get_config_entry(call)
+ memo_text = call.data[CONF_MEMO_TEXT]
+ module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS])
+ if not module:
+ raise ServiceValidationError("Module not found")
+ await module.set_memo_text(memo_text.async_render())
async def clear_cache(call: ServiceCall) -> None:
"""Handle a clear cache service call."""
- # clear the cache
+ entry = await get_config_entry(call)
with suppress(FileNotFoundError):
if call.data.get(CONF_ADDRESS):
await hass.async_add_executor_job(
os.unlink,
hass.config.path(
STORAGE_DIR,
- f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p",
+ f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p",
),
)
else:
await hass.async_add_executor_job(
shutil.rmtree,
- hass.config.path(
- STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/"
- ),
+ hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"),
)
# call a scan to repopulate
await scan(call)
@@ -92,28 +117,73 @@ def setup_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_SCAN,
scan,
- vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}),
+ vol.Any(
+ vol.Schema(
+ {
+ vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id),
+ }
+ ),
+ vol.Schema(
+ {
+ vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ )
+ }
+ ),
+ ),
)
hass.services.async_register(
DOMAIN,
SERVICE_SYNC,
syn_clock,
- vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}),
+ vol.Any(
+ vol.Schema(
+ {
+ vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id),
+ }
+ ),
+ vol.Schema(
+ {
+ vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ )
+ }
+ ),
+ ),
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_MEMO_TEXT,
set_memo_text,
- vol.Schema(
- {
- vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id),
- vol.Required(CONF_ADDRESS): vol.All(
- vol.Coerce(int), vol.Range(min=0, max=255)
- ),
- vol.Optional(CONF_MEMO_TEXT, default=""): cv.template,
- }
+ vol.Any(
+ vol.Schema(
+ {
+ vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id),
+ vol.Required(CONF_ADDRESS): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ vol.Optional(CONF_MEMO_TEXT, default=""): cv.template,
+ }
+ ),
+ vol.Schema(
+ {
+ vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ ),
+ vol.Required(CONF_ADDRESS): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ vol.Optional(CONF_MEMO_TEXT, default=""): cv.template,
+ }
+ ),
),
)
@@ -121,12 +191,26 @@ def setup_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_CLEAR_CACHE,
clear_cache,
- vol.Schema(
- {
- vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id),
- vol.Optional(CONF_ADDRESS): vol.All(
- vol.Coerce(int), vol.Range(min=0, max=255)
- ),
- }
+ vol.Any(
+ vol.Schema(
+ {
+ vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id),
+ vol.Optional(CONF_ADDRESS): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ }
+ ),
+ vol.Schema(
+ {
+ vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ ),
+ vol.Optional(CONF_ADDRESS): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ }
+ ),
),
)
diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml
index e3ecc3556f0..39886913692 100644
--- a/homeassistant/components/velbus/services.yaml
+++ b/homeassistant/components/velbus/services.yaml
@@ -1,29 +1,38 @@
sync_clock:
fields:
interface:
- required: true
example: "192.168.1.5:27015"
default: ""
selector:
text:
+ config_entry:
+ selector:
+ config_entry:
+ integration: velbus
scan:
fields:
interface:
- required: true
example: "192.168.1.5:27015"
default: ""
selector:
text:
+ config_entry:
+ selector:
+ config_entry:
+ integration: velbus
clear_cache:
fields:
interface:
- required: true
example: "192.168.1.5:27015"
default: ""
selector:
text:
+ config_entry:
+ selector:
+ config_entry:
+ integration: velbus
address:
required: false
selector:
@@ -34,11 +43,14 @@ clear_cache:
set_memo_text:
fields:
interface:
- required: true
example: "192.168.1.5:27015"
default: ""
selector:
text:
+ config_entry:
+ selector:
+ config_entry:
+ integration: velbus
address:
required: true
selector:
diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json
index 55c7fda84ac..69fc3d661e9 100644
--- a/homeassistant/components/velbus/strings.json
+++ b/homeassistant/components/velbus/strings.json
@@ -2,9 +2,9 @@
"config": {
"step": {
"user": {
- "title": "Define the velbus connection type",
+ "title": "Define the Velbus connection type",
"data": {
- "name": "The name for this velbus connection",
+ "name": "The name for this Velbus connection",
"port": "Connection string"
}
}
@@ -20,60 +20,88 @@
"exceptions": {
"invalid_hvac_mode": {
"message": "Climate mode {hvac_mode} is not supported."
+ },
+ "not_loaded": {
+ "message": "{target} is not loaded."
+ },
+ "integration_not_found": {
+ "message": "Integration \"{target}\" not found in registry."
}
},
"services": {
"sync_clock": {
"name": "Sync clock",
- "description": "Syncs the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.",
+ "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.",
"fields": {
"interface": {
"name": "Interface",
- "description": "The velbus interface to send the command to, this will be the same value as used during configuration."
+ "description": "The Velbus interface to send the command to, this will be the same value as used during configuration."
+ },
+ "config_entry": {
+ "name": "Config entry",
+ "description": "The config entry of the Velbus integration"
}
}
},
"scan": {
"name": "Scan",
- "description": "Scans the velbus modules, this will be need if you see unknown module warnings in the logs, or when you added new modules.",
+ "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.",
"fields": {
"interface": {
"name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]",
"description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]"
+ },
+ "config_entry": {
+ "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]",
+ "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]"
}
}
},
"clear_cache": {
"name": "Clear cache",
- "description": "Clears the velbuscache and then starts a new scan.",
+ "description": "Clears the Velbus cache and then starts a new scan.",
"fields": {
"interface": {
"name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]",
"description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]"
},
+ "config_entry": {
+ "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]",
+ "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]"
+ },
"address": {
"name": "Address",
- "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page.\n."
+ "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page."
}
}
},
"set_memo_text": {
"name": "Set memo text",
- "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text.\n.",
+ "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text.",
"fields": {
"interface": {
"name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]",
"description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]"
},
+ "config_entry": {
+ "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]",
+ "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]"
+ },
"address": {
"name": "Address",
- "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page.\n."
+ "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page."
},
"memo_text": {
"name": "Memo text",
- "description": "The actual text to be displayed. Text is limited to 64 characters.\n."
+ "description": "The actual text to be displayed. Text is limited to 64 characters."
}
}
}
+ },
+ "issues": {
+ "deprecated_interface_parameter": {
+ "title": "Deprecated 'interface' parameter",
+ "description": "The 'interface' parameter in the Velbus actions is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."
+ }
}
}
diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py
index f4bfa13b4d5..24f65aa3b0b 100644
--- a/homeassistant/components/velux/config_flow.py
+++ b/homeassistant/components/velux/config_flow.py
@@ -1,15 +1,19 @@
"""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
-import homeassistant.helpers.config_validation as cv
+from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN, LOGGER
-DATA_SCHEMA = vol.Schema(
+USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
@@ -17,9 +21,31 @@ DATA_SCHEMA = vol.Schema(
)
+async def _check_connection(host: str, password: str) -> dict[str, Any]:
+ """Check if we can connect to the Velux bridge."""
+ pyvlx = PyVLX(host=host, password=password)
+ try:
+ await pyvlx.connect()
+ await pyvlx.disconnect()
+ except (PyVLXException, ConnectionError) as err:
+ LOGGER.debug("Cannot connect: %s", err)
+ return {"base": "cannot_connect"}
+ except Exception as err: # noqa: BLE001
+ LOGGER.exception("Unexpected exception: %s", err)
+ return {"base": "unknown"}
+
+ return {}
+
+
class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for velux."""
+ VERSION = 1
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self.discovery_data: dict[str, Any] = {}
+
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
@@ -28,28 +54,78 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
-
- pyvlx = PyVLX(
- host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD]
+ errors = await _check_connection(
+ user_input[CONF_HOST], user_input[CONF_PASSWORD]
)
- try:
- await pyvlx.connect()
- await pyvlx.disconnect()
- except (PyVLXException, ConnectionError) as err:
- errors["base"] = "cannot_connect"
- LOGGER.debug("Cannot connect: %s", err)
- except Exception as err: # noqa: BLE001
- LOGGER.exception("Unexpected exception: %s", err)
- errors["base"] = "unknown"
- else:
+ if not errors:
return self.async_create_entry(
title=user_input[CONF_HOST],
data=user_input,
)
- data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input)
return self.async_show_form(
step_id="user",
- data_schema=data_schema,
+ data_schema=USER_SCHEMA,
errors=errors,
)
+
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle discovery by DHCP."""
+ # The hostname ends with the last 4 digits of the device MAC address.
+ self.discovery_data[CONF_HOST] = discovery_info.ip
+ self.discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress)
+ self.discovery_data[CONF_NAME] = discovery_info.hostname.upper().replace(
+ "LAN_", ""
+ )
+
+ await self.async_set_unique_id(self.discovery_data[CONF_NAME])
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: self.discovery_data[CONF_HOST]}
+ )
+
+ # Abort if config_entry already exists without unigue_id configured.
+ for entry in self.hass.config_entries.async_entries(DOMAIN):
+ if (
+ entry.data[CONF_HOST] == self.discovery_data[CONF_HOST]
+ and entry.unique_id is None
+ and entry.state is ConfigEntryState.LOADED
+ ):
+ self.hass.config_entries.async_update_entry(
+ entry=entry,
+ unique_id=self.discovery_data[CONF_NAME],
+ data={**entry.data, **self.discovery_data},
+ )
+ return self.async_abort(reason="already_configured")
+ self._async_abort_entries_match({CONF_HOST: self.discovery_data[CONF_HOST]})
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Prepare configuration for a discovered Velux device."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ errors = await _check_connection(
+ self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD]
+ )
+ if not errors:
+ return self.async_create_entry(
+ title=self.discovery_data[CONF_NAME],
+ data={**self.discovery_data, **user_input},
+ )
+
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): cv.string,
+ }
+ ),
+ errors=errors,
+ description_placeholders={
+ "name": self.discovery_data[CONF_NAME],
+ "host": self.discovery_data[CONF_HOST],
+ },
+ )
diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json
index 053b7fcc594..cb21fef299d 100644
--- a/homeassistant/components/velux/manifest.json
+++ b/homeassistant/components/velux/manifest.json
@@ -1,8 +1,14 @@
{
"domain": "velux",
"name": "Velux",
- "codeowners": ["@Julius2342", "@DeerMaximum"],
+ "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"],
"config_flow": true,
+ "dhcp": [
+ {
+ "hostname": "velux_klf*",
+ "macaddress": "646184*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/velux",
"iot_class": "local_polling",
"loggers": ["pyvlx"],
diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json
index 5b7b459a3f7..0cf578732fb 100644
--- a/homeassistant/components/velux/strings.json
+++ b/homeassistant/components/velux/strings.json
@@ -2,11 +2,16 @@
"config": {
"step": {
"user": {
- "title": "Setup Velux",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
}
+ },
+ "discovery_confirm": {
+ "description": "Please enter the password for {name} ({host})",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ }
}
},
"error": {
diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py
index c5323e1e9a8..50f6508e7ed 100644
--- a/homeassistant/components/venstar/climate.py
+++ b/homeassistant/components/venstar/climate.py
@@ -32,7 +32,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py
index e512676de9a..9b8ae42f620 100644
--- a/homeassistant/components/vera/light.py
+++ b/homeassistant/components/vera/light.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
diff --git a/homeassistant/components/vera/strings.json b/homeassistant/components/vera/strings.json
index 3bfb58f8104..dcb8f6fc3a2 100644
--- a/homeassistant/components/vera/strings.json
+++ b/homeassistant/components/vera/strings.json
@@ -8,8 +8,8 @@
"user": {
"data": {
"vera_controller_url": "Controller URL",
- "lights": "Vera switch device ids to treat as lights in Home Assistant.",
- "exclude": "Vera device ids to exclude from Home Assistant."
+ "lights": "Vera switch device IDs to treat as lights in Home Assistant.",
+ "exclude": "Vera device IDs to exclude from Home Assistant."
},
"data_description": {
"vera_controller_url": "It should look like this: http://192.168.1.161:3480"
@@ -21,7 +21,7 @@
"step": {
"init": {
"title": "Vera controller options",
- "description": "See the vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here will need a restart to the home assistant server. To clear values, provide a space.",
+ "description": "See the Vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here require a restart of the Home Assistant server. To clear values, provide a space.",
"data": {
"lights": "[%key:component::vera::config::step::user::data::lights%]",
"exclude": "[%key:component::vera::config::step::user::data::exclude%]"
diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py
index ed4a8edf32c..cbd69ba0a81 100644
--- a/homeassistant/components/versasense/__init__.py
+++ b/homeassistant/components/versasense/__init__.py
@@ -7,8 +7,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index 0993743d461..27e626faeac 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -9,19 +9,26 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .common import async_process_devices
+from .common import async_generate_device_list
from .const import (
DOMAIN,
SERVICE_UPDATE_DEVS,
+ VS_COORDINATOR,
+ VS_DEVICES,
VS_DISCOVERY,
- VS_FANS,
- VS_LIGHTS,
VS_MANAGER,
- VS_SENSORS,
- VS_SWITCHES,
)
+from .coordinator import VeSyncDataCoordinator
-PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH]
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.FAN,
+ Platform.HUMIDIFIER,
+ Platform.LIGHT,
+ Platform.NUMBER,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
_LOGGER = logging.getLogger(__name__)
@@ -41,90 +48,33 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
_LOGGER.error("Unable to login to the VeSync server")
return False
- device_dict = await async_process_devices(hass, manager)
-
- forward_setups = hass.config_entries.async_forward_entry_setups
-
hass.data[DOMAIN] = {}
hass.data[DOMAIN][VS_MANAGER] = manager
- switches = hass.data[DOMAIN][VS_SWITCHES] = []
- fans = hass.data[DOMAIN][VS_FANS] = []
- lights = hass.data[DOMAIN][VS_LIGHTS] = []
- sensors = hass.data[DOMAIN][VS_SENSORS] = []
- platforms = []
+ coordinator = VeSyncDataCoordinator(hass, manager)
- if device_dict[VS_SWITCHES]:
- switches.extend(device_dict[VS_SWITCHES])
- platforms.append(Platform.SWITCH)
+ # Store coordinator at domain level since only single integration instance is permitted.
+ hass.data[DOMAIN][VS_COORDINATOR] = coordinator
- if device_dict[VS_FANS]:
- fans.extend(device_dict[VS_FANS])
- platforms.append(Platform.FAN)
+ hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager)
- if device_dict[VS_LIGHTS]:
- lights.extend(device_dict[VS_LIGHTS])
- platforms.append(Platform.LIGHT)
-
- if device_dict[VS_SENSORS]:
- sensors.extend(device_dict[VS_SENSORS])
- platforms.append(Platform.SENSOR)
-
- await hass.config_entries.async_forward_entry_setups(config_entry, platforms)
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def async_new_device_discovery(service: ServiceCall) -> None:
"""Discover if new devices should be added."""
manager = hass.data[DOMAIN][VS_MANAGER]
- switches = hass.data[DOMAIN][VS_SWITCHES]
- fans = hass.data[DOMAIN][VS_FANS]
- lights = hass.data[DOMAIN][VS_LIGHTS]
- sensors = hass.data[DOMAIN][VS_SENSORS]
+ devices = hass.data[DOMAIN][VS_DEVICES]
- dev_dict = await async_process_devices(hass, manager)
- switch_devs = dev_dict.get(VS_SWITCHES, [])
- fan_devs = dev_dict.get(VS_FANS, [])
- light_devs = dev_dict.get(VS_LIGHTS, [])
- sensor_devs = dev_dict.get(VS_SENSORS, [])
+ new_devices = await async_generate_device_list(hass, manager)
- switch_set = set(switch_devs)
- new_switches = list(switch_set.difference(switches))
- if new_switches and switches:
- switches.extend(new_switches)
- async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SWITCHES), new_switches)
+ device_set = set(new_devices)
+ new_devices = list(device_set.difference(devices))
+ if new_devices and devices:
+ devices.extend(new_devices)
+ async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices)
return
- if new_switches and not switches:
- switches.extend(new_switches)
- hass.async_create_task(forward_setups(config_entry, [Platform.SWITCH]))
-
- fan_set = set(fan_devs)
- new_fans = list(fan_set.difference(fans))
- if new_fans and fans:
- fans.extend(new_fans)
- async_dispatcher_send(hass, VS_DISCOVERY.format(VS_FANS), new_fans)
- return
- if new_fans and not fans:
- fans.extend(new_fans)
- hass.async_create_task(forward_setups(config_entry, [Platform.FAN]))
-
- light_set = set(light_devs)
- new_lights = list(light_set.difference(lights))
- if new_lights and lights:
- lights.extend(new_lights)
- async_dispatcher_send(hass, VS_DISCOVERY.format(VS_LIGHTS), new_lights)
- return
- if new_lights and not lights:
- lights.extend(new_lights)
- hass.async_create_task(forward_setups(config_entry, [Platform.LIGHT]))
-
- sensor_set = set(sensor_devs)
- new_sensors = list(sensor_set.difference(sensors))
- if new_sensors and sensors:
- sensors.extend(new_sensors)
- async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SENSORS), new_sensors)
- return
- if new_sensors and not sensors:
- sensors.extend(new_sensors)
- hass.async_create_task(forward_setups(config_entry, [Platform.SENSOR]))
+ if new_devices and not devices:
+ devices.extend(new_devices)
hass.services.async_register(
DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery
@@ -135,18 +85,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- in_use_platforms = []
- if hass.data[DOMAIN][VS_SWITCHES]:
- in_use_platforms.append(Platform.SWITCH)
- if hass.data[DOMAIN][VS_FANS]:
- in_use_platforms.append(Platform.FAN)
- if hass.data[DOMAIN][VS_LIGHTS]:
- in_use_platforms.append(Platform.LIGHT)
- if hass.data[DOMAIN][VS_SENSORS]:
- in_use_platforms.append(Platform.SENSOR)
- unload_ok = await hass.config_entries.async_unload_platforms(
- entry, in_use_platforms
- )
+
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data.pop(DOMAIN)
diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py
new file mode 100644
index 00000000000..dd1b6398c06
--- /dev/null
+++ b/homeassistant/components/vesync/binary_sensor.py
@@ -0,0 +1,106 @@
+"""Binary Sensor for VeSync."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .common import rgetattr
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """A class that describes custom binary sensor entities."""
+
+ is_on: Callable[[VeSyncBaseDevice], bool]
+
+
+SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = (
+ VeSyncBinarySensorEntityDescription(
+ key="water_lacks",
+ translation_key="water_lacks",
+ is_on=lambda device: device.water_lacks,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ ),
+ VeSyncBinarySensorEntityDescription(
+ key="details.water_tank_lifted",
+ translation_key="water_tank_lifted",
+ is_on=lambda device: device.details["water_tank_lifted"],
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary_sensor platform."""
+
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
+ @callback
+ def discover(devices):
+ """Add new devices to platform."""
+ _setup_entities(devices, async_add_entities)
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
+ )
+
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+
+
+@callback
+def _setup_entities(devices, async_add_entities, coordinator):
+ """Add entity."""
+ async_add_entities(
+ (
+ VeSyncBinarySensor(dev, description, coordinator)
+ for dev in devices
+ for description in SENSOR_DESCRIPTIONS
+ if rgetattr(dev, description.key) is not None
+ ),
+ )
+
+
+class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity):
+ """Vesync binary sensor class."""
+
+ entity_description: VeSyncBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ device: VeSyncBaseDevice,
+ description: VeSyncBinarySensorEntityDescription,
+ coordinator: VeSyncDataCoordinator,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(device, coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{super().unique_id}-{description.key}"
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the binary sensor is on."""
+ _LOGGER.debug(rgetattr(self.device, self.entity_description.key))
+ return self.entity_description.is_on(self.device)
diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py
index 5f7b2a3a29e..e2f4e1db2e4 100644
--- a/homeassistant/components/vesync/common.py
+++ b/homeassistant/components/vesync/common.py
@@ -2,43 +2,55 @@
import logging
-from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES
+from pyvesync import VeSync
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.core import HomeAssistant
+
+from .const import VeSyncHumidifierDevice
_LOGGER = logging.getLogger(__name__)
-async def async_process_devices(hass, manager):
+def rgetattr(obj: object, attr: str):
+ """Return a string in the form word.1.2.3 and return the item as 3. Note that this last value could be in a dict as well."""
+ _this_func = rgetattr
+ sp = attr.split(".", 1)
+ if len(sp) == 1:
+ left, right = sp[0], ""
+ else:
+ left, right = sp
+
+ if isinstance(obj, dict):
+ obj = obj.get(left)
+ elif hasattr(obj, left):
+ obj = getattr(obj, left)
+ else:
+ return None
+
+ if right:
+ obj = _this_func(obj, right)
+
+ return obj
+
+
+async def async_generate_device_list(
+ hass: HomeAssistant, manager: VeSync
+) -> list[VeSyncBaseDevice]:
"""Assign devices to proper component."""
- devices = {}
- devices[VS_SWITCHES] = []
- devices[VS_FANS] = []
- devices[VS_LIGHTS] = []
- devices[VS_SENSORS] = []
+ devices: list[VeSyncBaseDevice] = []
await hass.async_add_executor_job(manager.update)
- if manager.fans:
- devices[VS_FANS].extend(manager.fans)
- # Expose fan sensors separately
- devices[VS_SENSORS].extend(manager.fans)
- _LOGGER.debug("%d VeSync fans found", len(manager.fans))
-
- if manager.bulbs:
- devices[VS_LIGHTS].extend(manager.bulbs)
- _LOGGER.debug("%d VeSync lights found", len(manager.bulbs))
-
- if manager.outlets:
- devices[VS_SWITCHES].extend(manager.outlets)
- # Expose outlets' voltage, power & energy usage as separate sensors
- devices[VS_SENSORS].extend(manager.outlets)
- _LOGGER.debug("%d VeSync outlets found", len(manager.outlets))
-
- if manager.switches:
- for switch in manager.switches:
- if not switch.is_dimmable():
- devices[VS_SWITCHES].append(switch)
- else:
- devices[VS_LIGHTS].append(switch)
- _LOGGER.debug("%d VeSync switches found", len(manager.switches))
+ devices.extend(manager.fans)
+ devices.extend(manager.bulbs)
+ devices.extend(manager.outlets)
+ devices.extend(manager.switches)
return devices
+
+
+def is_humidifier(device: VeSyncBaseDevice) -> bool:
+ """Check if the device represents a humidifier."""
+
+ return isinstance(device, VeSyncHumidifierDevice)
diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py
index 6115cb9ee76..e19c46e5490 100644
--- a/homeassistant/components/vesync/config_flow.py
+++ b/homeassistant/components/vesync/config_flow.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py
index b1bad8cfa11..34454081567 100644
--- a/homeassistant/components/vesync/const.py
+++ b/homeassistant/components/vesync/const.py
@@ -1,14 +1,36 @@
"""Constants for VeSync Component."""
+from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S
+
DOMAIN = "vesync"
VS_DISCOVERY = "vesync_discovery_{}"
SERVICE_UPDATE_DEVS = "update_devices"
-VS_SWITCHES = "switches"
-VS_FANS = "fans"
-VS_LIGHTS = "lights"
-VS_SENSORS = "sensors"
+UPDATE_INTERVAL = 60
+"""
+Update interval for DataCoordinator.
+
+The vesync daily quota formula is 3200 + 1500 * device_count.
+
+An interval of 60 seconds amounts 1440 calls/day which
+would be below the 4700 daily quota. For 2 devices, the
+total would be 2880.
+
+Using 30 seconds interval gives 8640 for 3 devices which
+exceeds the quota of 7700.
+"""
+VS_DEVICES = "devices"
+VS_COORDINATOR = "coordinator"
VS_MANAGER = "manager"
+VS_NUMBERS = "numbers"
+
+VS_HUMIDIFIER_MODE_AUTO = "auto"
+VS_HUMIDIFIER_MODE_HUMIDITY = "humidity"
+VS_HUMIDIFIER_MODE_MANUAL = "manual"
+VS_HUMIDIFIER_MODE_SLEEP = "sleep"
+
+VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S
+"""Humidifier device types"""
DEV_TYPE_TO_HA = {
"wifi-switch-1.3": "outlet",
@@ -26,6 +48,7 @@ DEV_TYPE_TO_HA = {
"EverestAir": "fan",
"Vital200S": "fan",
"Vital100S": "fan",
+ "SmartTowerFan": "fan",
"ESD16": "walldimmer",
"ESWD16": "walldimmer",
"ESL100": "bulb-dimmable",
@@ -42,6 +65,7 @@ SKU_TO_BASE_DEVICE = {
"Core300S": "Core300S",
"LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S
"LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S
+ "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S
"Core400S": "Core400S",
"LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S
"LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S
@@ -68,4 +92,9 @@ SKU_TO_BASE_DEVICE = {
"LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir
"LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir
"LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir
+ "SmartTowerFan": "SmartTowerFan",
+ "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan
+ "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan
+ "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan
+ "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan
}
diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py
new file mode 100644
index 00000000000..f3df2970fdb
--- /dev/null
+++ b/homeassistant/components/vesync/coordinator.py
@@ -0,0 +1,43 @@
+"""Class to manage VeSync data updates."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from pyvesync import VeSync
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import UPDATE_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class VeSyncDataCoordinator(DataUpdateCoordinator[None]):
+ """Class representing data coordinator for VeSync devices."""
+
+ def __init__(self, hass: HomeAssistant, manager: VeSync) -> None:
+ """Initialize."""
+ self._manager = manager
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="VeSyncDataCoordinator",
+ update_interval=timedelta(seconds=UPDATE_INTERVAL),
+ )
+
+ async def _async_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+
+ return await self.hass.async_add_executor_job(self.update_data_all)
+
+ def update_data_all(self) -> None:
+ """Update all the devices."""
+
+ # Using `update_all_devices` instead of `update` to avoid fetching device list every time.
+ self._manager.update_all_devices()
+ # Vesync updates energy on applicable devices every 6 hours
+ self._manager.update_energy()
diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py
index fd636561e9e..3aa7b008cc5 100644
--- a/homeassistant/components/vesync/entity.py
+++ b/homeassistant/components/vesync/entity.py
@@ -1,22 +1,24 @@
"""Common entity for VeSync Component."""
-from typing import Any
-
from pyvesync.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity import Entity, ToggleEntity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
+from .coordinator import VeSyncDataCoordinator
-class VeSyncBaseEntity(Entity):
+class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]):
"""Base class for VeSync Entity Representations."""
_attr_has_entity_name = True
- def __init__(self, device: VeSyncBaseDevice) -> None:
+ def __init__(
+ self, device: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator
+ ) -> None:
"""Initialize the VeSync device."""
+ super().__init__(coordinator)
self.device = device
self._attr_unique_id = self.base_unique_id
@@ -45,25 +47,3 @@ class VeSyncBaseEntity(Entity):
manufacturer="VeSync",
sw_version=self.device.current_firm_version,
)
-
- def update(self) -> None:
- """Update vesync device."""
- self.device.update()
-
-
-class VeSyncDevice(VeSyncBaseEntity, ToggleEntity):
- """Base class for VeSync Device Representations."""
-
- @property
- def details(self):
- """Provide access to the device details dictionary."""
- return self.device.details
-
- @property
- def is_on(self) -> bool:
- """Return True if device is on."""
- return self.device.device_status == "on"
-
- def turn_off(self, **kwargs: Any) -> None:
- """Turn the device off."""
- self.device.turn_off()
diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py
index 5be6a06e1d0..21a92a22db2 100644
--- a/homeassistant/components/vesync/fan.py
+++ b/homeassistant/components/vesync/fan.py
@@ -6,6 +6,8 @@ import logging
import math
from typing import Any
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -17,8 +19,16 @@ from homeassistant.util.percentage import (
)
from homeassistant.util.scaling import int_states_in_range
-from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS
-from .entity import VeSyncDevice
+from .const import (
+ DEV_TYPE_TO_HA,
+ DOMAIN,
+ SKU_TO_BASE_DEVICE,
+ VS_COORDINATOR,
+ VS_DEVICES,
+ VS_DISCOVERY,
+)
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -26,6 +36,9 @@ FAN_MODE_AUTO = "auto"
FAN_MODE_SLEEP = "sleep"
FAN_MODE_PET = "pet"
FAN_MODE_TURBO = "turbo"
+FAN_MODE_ADVANCED_SLEEP = "advancedSleep"
+FAN_MODE_NORMAL = "normal"
+
PRESET_MODES = {
"LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP],
@@ -36,6 +49,12 @@ PRESET_MODES = {
"EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO],
"Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET],
"Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET],
+ "SmartTowerFan": [
+ FAN_MODE_ADVANCED_SLEEP,
+ FAN_MODE_AUTO,
+ FAN_MODE_TURBO,
+ FAN_MODE_NORMAL,
+ ],
}
SPEED_RANGE = { # off is not included
"LV-PUR131S": (1, 3),
@@ -46,6 +65,7 @@ SPEED_RANGE = { # off is not included
"EverestAir": (1, 3),
"Vital200S": (1, 4),
"Vital100S": (1, 4),
+ "SmartTowerFan": (1, 13),
}
@@ -56,35 +76,37 @@ async def async_setup_entry(
) -> None:
"""Set up the VeSync fan platform."""
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
@callback
def discover(devices):
"""Add new devices to platform."""
- _setup_entities(devices, async_add_entities)
+ _setup_entities(devices, async_add_entities, coordinator)
config_entry.async_on_unload(
- async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), discover)
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
- """Check if device is online and add entity."""
- entities = []
- for dev in devices:
- if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "fan":
- entities.append(VeSyncFanHA(dev))
- else:
- _LOGGER.warning(
- "%s - Unknown device type - %s", dev.device_name, dev.device_type
- )
- continue
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Check if device is fan and add entity."""
+ entities = [
+ VeSyncFanHA(dev, coordinator)
+ for dev in devices
+ if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan"
+ ]
async_add_entities(entities, update_before_add=True)
-class VeSyncFanHA(VeSyncDevice, FanEntity):
+class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
"""Representation of a VeSync fan."""
_attr_supported_features = (
@@ -96,11 +118,18 @@ class VeSyncFanHA(VeSyncDevice, FanEntity):
_attr_name = None
_attr_translation_key = "vesync"
- def __init__(self, fan) -> None:
+ def __init__(
+ self, fan: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator
+ ) -> None:
"""Initialize the VeSync fan device."""
- super().__init__(fan)
+ super().__init__(fan, coordinator)
self.smartfan = fan
+ @property
+ def is_on(self) -> bool:
+ """Return True if device is on."""
+ return self.device.device_status == "on"
+
@property
def percentage(self) -> int | None:
"""Return the current speed."""
@@ -132,11 +161,6 @@ class VeSyncFanHA(VeSyncDevice, FanEntity):
return self.smartfan.mode
return None
- @property
- def unique_info(self):
- """Return the ID of this fan."""
- return self.smartfan.uuid
-
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the fan."""
@@ -193,10 +217,14 @@ class VeSyncFanHA(VeSyncDevice, FanEntity):
self.smartfan.auto_mode()
elif preset_mode == FAN_MODE_SLEEP:
self.smartfan.sleep_mode()
+ elif preset_mode == FAN_MODE_ADVANCED_SLEEP:
+ self.smartfan.advanced_sleep_mode()
elif preset_mode == FAN_MODE_PET:
self.smartfan.pet_mode()
elif preset_mode == FAN_MODE_TURBO:
self.smartfan.turbo_mode()
+ elif preset_mode == FAN_MODE_NORMAL:
+ self.smartfan.normal_mode()
self.schedule_update_ha_state()
@@ -213,3 +241,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity):
if percentage is None:
percentage = 50
self.set_percentage(percentage)
+
+ def turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ self.device.turn_off()
diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py
new file mode 100644
index 00000000000..3bae838196f
--- /dev/null
+++ b/homeassistant/components/vesync/humidifier.py
@@ -0,0 +1,192 @@
+"""Support for VeSync humidifiers."""
+
+import logging
+from typing import Any
+
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.components.humidifier import (
+ MODE_AUTO,
+ MODE_NORMAL,
+ MODE_SLEEP,
+ HumidifierEntity,
+ HumidifierEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .common import is_humidifier
+from .const import (
+ DOMAIN,
+ VS_COORDINATOR,
+ VS_DEVICES,
+ VS_DISCOVERY,
+ VS_HUMIDIFIER_MODE_AUTO,
+ VS_HUMIDIFIER_MODE_HUMIDITY,
+ VS_HUMIDIFIER_MODE_MANUAL,
+ VS_HUMIDIFIER_MODE_SLEEP,
+ VeSyncHumidifierDevice,
+)
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+MIN_HUMIDITY = 30
+MAX_HUMIDITY = 80
+
+VS_TO_HA_MODE_MAP = {
+ VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO,
+ VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO,
+ VS_HUMIDIFIER_MODE_MANUAL: MODE_NORMAL,
+ VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP,
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the VeSync humidifier platform."""
+
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
+ @callback
+ def discover(devices):
+ """Add new devices to platform."""
+ _setup_entities(devices, async_add_entities, coordinator)
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
+ )
+
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+
+
+@callback
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities: AddEntitiesCallback,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Add humidifier entities."""
+ async_add_entities(
+ VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev)
+ )
+
+
+def _get_ha_mode(vs_mode: str) -> str | None:
+ ha_mode = VS_TO_HA_MODE_MAP.get(vs_mode)
+ if ha_mode is None:
+ _LOGGER.warning("Unknown mode '%s'", vs_mode)
+ return ha_mode
+
+
+class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity):
+ """Representation of a VeSync humidifier."""
+
+ # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name
+ _attr_name = None
+
+ _attr_max_humidity = MAX_HUMIDITY
+ _attr_min_humidity = MIN_HUMIDITY
+ _attr_supported_features = HumidifierEntityFeature.MODES
+
+ device: VeSyncHumidifierDevice
+
+ def __init__(
+ self,
+ device: VeSyncBaseDevice,
+ coordinator: VeSyncDataCoordinator,
+ ) -> None:
+ """Initialize the VeSyncHumidifierHA device."""
+ super().__init__(device, coordinator)
+
+ # 2 Vesync humidifier modes (humidity and auto) maps to the HA mode auto.
+ # They are on different devices though. We need to map HA mode to the
+ # device specific mode when setting it.
+
+ self._ha_to_vs_mode_map: dict[str, str] = {}
+ self._available_modes: list[str] = []
+
+ # Populate maps once.
+ for vs_mode in self.device.mist_modes:
+ ha_mode = _get_ha_mode(vs_mode)
+ if ha_mode:
+ self._available_modes.append(ha_mode)
+ self._ha_to_vs_mode_map[ha_mode] = vs_mode
+
+ def _get_vs_mode(self, ha_mode: str) -> str | None:
+ return self._ha_to_vs_mode_map.get(ha_mode)
+
+ @property
+ def available_modes(self) -> list[str]:
+ """Return the available mist modes."""
+ return self._available_modes
+
+ @property
+ def current_humidity(self) -> int:
+ """Return the current humidity."""
+ return self.device.humidity
+
+ @property
+ def target_humidity(self) -> int:
+ """Return the humidity we try to reach."""
+ return self.device.auto_humidity
+
+ @property
+ def mode(self) -> str | None:
+ """Get the current preset mode."""
+ return None if self.device.mode is None else _get_ha_mode(self.device.mode)
+
+ def set_humidity(self, humidity: int) -> None:
+ """Set the target humidity of the device."""
+ if not self.device.set_humidity(humidity):
+ raise HomeAssistantError(
+ f"An error occurred while setting humidity {humidity}."
+ )
+
+ def set_mode(self, mode: str) -> None:
+ """Set the mode of the device."""
+ if mode not in self.available_modes:
+ raise HomeAssistantError(
+ f"{mode} is not one of the valid available modes: {self.available_modes}"
+ )
+ if not self.device.set_humidity_mode(self._get_vs_mode(mode)):
+ raise HomeAssistantError(f"An error occurred while setting mode {mode}.")
+
+ if mode == MODE_SLEEP:
+ # We successfully changed the mode. Consider it a success even if display operation fails.
+ self.device.set_display(False)
+
+ # Changing mode while humidifier is off actually turns it on, as per the app. But
+ # the library does not seem to update the device_status. It is also possible that
+ # other attributes get updated. Scheduling a forced refresh to get device status.
+ # updated.
+ self.schedule_update_ha_state(force_refresh=True)
+
+ def turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ success = self.device.turn_on()
+ if not success:
+ raise HomeAssistantError("An error occurred while turning on.")
+
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ success = self.device.turn_off()
+ if not success:
+ raise HomeAssistantError("An error occurred while turning off.")
+
+ self.schedule_update_ha_state()
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if device is on."""
+ return self.device.device_status == "on"
diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json
index e4769acc9a5..c11bd002049 100644
--- a/homeassistant/components/vesync/icons.json
+++ b/homeassistant/components/vesync/icons.json
@@ -7,6 +7,7 @@
"state": {
"auto": "mdi:fan-auto",
"sleep": "mdi:sleep",
+ "advanced_sleep": "mdi:sleep",
"pet": "mdi:paw",
"turbo": "mdi:weather-tornado"
}
diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py
index 5b08b92f75a..40f68986145 100644
--- a/homeassistant/components/vesync/light.py
+++ b/homeassistant/components/vesync/light.py
@@ -3,6 +3,8 @@
import logging
from typing import Any
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -15,8 +17,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util
-from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS
-from .entity import VeSyncDevice
+from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
MAX_MIREDS = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds
@@ -30,41 +33,47 @@ async def async_setup_entry(
) -> None:
"""Set up lights."""
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
@callback
def discover(devices):
"""Add new devices to platform."""
- _setup_entities(devices, async_add_entities)
+ _setup_entities(devices, async_add_entities, coordinator)
config_entry.async_on_unload(
- async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_LIGHTS), discover)
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
- """Check if device is online and add entity."""
- entities = []
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Check if device is a light and add entity."""
+ entities: list[VeSyncBaseLightHA] = []
for dev in devices:
if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"):
- entities.append(VeSyncDimmableLightHA(dev))
+ entities.append(VeSyncDimmableLightHA(dev, coordinator))
elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",):
- entities.append(VeSyncTunableWhiteLightHA(dev))
- else:
- _LOGGER.debug(
- "%s - Unknown device type - %s", dev.device_name, dev.device_type
- )
- continue
+ entities.append(VeSyncTunableWhiteLightHA(dev, coordinator))
async_add_entities(entities, update_before_add=True)
-class VeSyncBaseLight(VeSyncDevice, LightEntity):
+class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity):
"""Base class for VeSync Light Devices Representations."""
_attr_name = None
+ @property
+ def is_on(self) -> bool:
+ """Return True if device is on."""
+ return self.device.device_status == "on"
+
@property
def brightness(self) -> int:
"""Get light brightness."""
@@ -130,15 +139,19 @@ class VeSyncBaseLight(VeSyncDevice, LightEntity):
# send turn_on command to pyvesync api
self.device.turn_on()
+ def turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ self.device.turn_off()
-class VeSyncDimmableLightHA(VeSyncBaseLight, LightEntity):
+
+class VeSyncDimmableLightHA(VeSyncBaseLightHA, LightEntity):
"""Representation of a VeSync dimmable light device."""
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
-class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity):
+class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity):
"""Representation of a VeSync Tunable White Light device."""
_attr_color_mode = ColorMode.COLOR_TEMP
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
index c5926cc224a..b3697844f19 100644
--- a/homeassistant/components/vesync/manifest.json
+++ b/homeassistant/components/vesync/manifest.json
@@ -1,10 +1,16 @@
{
"domain": "vesync",
"name": "VeSync",
- "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"],
+ "codeowners": [
+ "@markperdue",
+ "@webdjoe",
+ "@thegardenmonkey",
+ "@cdnninja",
+ "@iprak"
+ ],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
- "requirements": ["pyvesync==2.1.12"]
+ "requirements": ["pyvesync==2.1.17"]
}
diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py
new file mode 100644
index 00000000000..3c43cce28cf
--- /dev/null
+++ b/homeassistant/components/vesync/number.py
@@ -0,0 +1,114 @@
+"""Support for VeSync numeric entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.components.number import (
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .common import is_humidifier
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class VeSyncNumberEntityDescription(NumberEntityDescription):
+ """Class to describe a Vesync number entity."""
+
+ exists_fn: Callable[[VeSyncBaseDevice], bool]
+ value_fn: Callable[[VeSyncBaseDevice], float]
+ set_value_fn: Callable[[VeSyncBaseDevice, float], bool]
+
+
+NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [
+ VeSyncNumberEntityDescription(
+ key="mist_level",
+ translation_key="mist_level",
+ native_min_value=1,
+ native_max_value=9,
+ native_step=1,
+ mode=NumberMode.SLIDER,
+ exists_fn=is_humidifier,
+ set_value_fn=lambda device, value: device.set_mist_level(value),
+ value_fn=lambda device: device.mist_level,
+ )
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up number entities."""
+
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
+ @callback
+ def discover(devices):
+ """Add new devices to platform."""
+ _setup_entities(devices, async_add_entities, coordinator)
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
+ )
+
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+
+
+@callback
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities: AddEntitiesCallback,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Add number entities."""
+
+ async_add_entities(
+ VeSyncNumberEntity(dev, description, coordinator)
+ for dev in devices
+ for description in NUMBER_DESCRIPTIONS
+ if description.exists_fn(dev)
+ )
+
+
+class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity):
+ """A class to set numeric options on Vesync device."""
+
+ entity_description: VeSyncNumberEntityDescription
+
+ def __init__(
+ self,
+ device: VeSyncBaseDevice,
+ description: VeSyncNumberEntityDescription,
+ coordinator: VeSyncDataCoordinator,
+ ) -> None:
+ """Initialize the VeSync number device."""
+ super().__init__(device, coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{super().unique_id}-{description.key}"
+
+ @property
+ def native_value(self) -> float:
+ """Return the value reported by the number."""
+ return self.entity_description.value_fn(self.device)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set new value."""
+ if await self.hass.async_add_executor_job(
+ self.entity_description.set_value_fn, self.device, value
+ ):
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py
index 79061ec0c4c..bf52050d745 100644
--- a/homeassistant/components/vesync/sensor.py
+++ b/homeassistant/components/vesync/sensor.py
@@ -6,9 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import logging
-from pyvesync.vesyncfan import VeSyncAirBypass
-from pyvesync.vesyncoutlet import VeSyncOutlet
-from pyvesync.vesyncswitch import VeSyncSwitch
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -30,7 +28,16 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS
+from .common import is_humidifier
+from .const import (
+ DEV_TYPE_TO_HA,
+ DOMAIN,
+ SKU_TO_BASE_DEVICE,
+ VS_COORDINATOR,
+ VS_DEVICES,
+ VS_DISCOVERY,
+)
+from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -40,14 +47,10 @@ _LOGGER = logging.getLogger(__name__)
class VeSyncSensorEntityDescription(SensorEntityDescription):
"""Describe VeSync sensor entity."""
- value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType]
+ value_fn: Callable[[VeSyncBaseDevice], StateType]
- exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = (
- lambda _: True
- )
- update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = (
- lambda _: None
- )
+ exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True
+ update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None
def update_energy(device):
@@ -177,6 +180,14 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
update_fn=update_energy,
exists_fn=lambda device: ha_dev_type(device) == "outlet",
),
+ VeSyncSensorEntityDescription(
+ key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda device: device.details["humidity"],
+ exists_fn=is_humidifier,
+ ),
)
@@ -187,24 +198,31 @@ async def async_setup_entry(
) -> None:
"""Set up switches."""
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
@callback
def discover(devices):
"""Add new devices to platform."""
- _setup_entities(devices, async_add_entities)
+ _setup_entities(devices, async_add_entities, coordinator)
config_entry.async_on_unload(
- async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SENSORS), discover)
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities: AddEntitiesCallback,
+ coordinator: VeSyncDataCoordinator,
+):
"""Check if device is online and add entity."""
+
async_add_entities(
(
- VeSyncSensorEntity(dev, description)
+ VeSyncSensorEntity(dev, description, coordinator)
for dev in devices
for description in SENSORS
if description.exists_fn(dev)
@@ -220,11 +238,12 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity):
def __init__(
self,
- device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch,
+ device: VeSyncBaseDevice,
description: VeSyncSensorEntityDescription,
+ coordinator: VeSyncDataCoordinator,
) -> None:
"""Initialize the VeSync outlet device."""
- super().__init__(device)
+ super().__init__(device, coordinator)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}-{description.key}"
diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json
index b6e4e2fd957..3eb2a0c3fd5 100644
--- a/homeassistant/components/vesync/strings.json
+++ b/homeassistant/components/vesync/strings.json
@@ -43,6 +43,19 @@
"name": "Current voltage"
}
},
+ "binary_sensor": {
+ "water_lacks": {
+ "name": "Low water"
+ },
+ "water_tank_lifted": {
+ "name": "Water tank lifted"
+ }
+ },
+ "number": {
+ "mist_level": {
+ "name": "Mist level"
+ }
+ },
"fan": {
"vesync": {
"state_attributes": {
@@ -50,6 +63,7 @@
"state": {
"auto": "Auto",
"sleep": "Sleep",
+ "advanced_sleep": "Advanced sleep",
"pet": "Pet",
"turbo": "Turbo"
}
diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py
index a162a648ad7..ef8e6c6051f 100644
--- a/homeassistant/components/vesync/switch.py
+++ b/homeassistant/components/vesync/switch.py
@@ -3,14 +3,17 @@
import logging
from typing import Any
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES
-from .entity import VeSyncDevice
+from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -22,37 +25,38 @@ async def async_setup_entry(
) -> None:
"""Set up switches."""
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
@callback
def discover(devices):
"""Add new devices to platform."""
- _setup_entities(devices, async_add_entities)
+ _setup_entities(devices, async_add_entities, coordinator)
config_entry.async_on_unload(
- async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SWITCHES), discover)
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
- """Check if device is online and add entity."""
- entities = []
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Check if device is a switch and add entity."""
+ entities: list[VeSyncBaseSwitch] = []
for dev in devices:
if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet":
- entities.append(VeSyncSwitchHA(dev))
+ entities.append(VeSyncSwitchHA(dev, coordinator))
elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch":
- entities.append(VeSyncLightSwitch(dev))
- else:
- _LOGGER.warning(
- "%s - Unknown device type - %s", dev.device_name, dev.device_type
- )
- continue
+ entities.append(VeSyncLightSwitch(dev, coordinator))
async_add_entities(entities, update_before_add=True)
-class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity):
+class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity):
"""Base class for VeSync switch Device Representations."""
_attr_name = None
@@ -61,25 +65,33 @@ class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity):
"""Turn the device on."""
self.device.turn_on()
+ @property
+ def is_on(self) -> bool:
+ """Return True if device is on."""
+ return self.device.device_status == "on"
+
+ def turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ self.device.turn_off()
+
class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity):
"""Representation of a VeSync switch."""
- def __init__(self, plug):
+ def __init__(
+ self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator
+ ) -> None:
"""Initialize the VeSync switch device."""
- super().__init__(plug)
+ super().__init__(plug, coordinator)
self.smartplug = plug
- def update(self) -> None:
- """Update outlet details and energy usage."""
- self.smartplug.update()
- self.smartplug.update_energy()
-
class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity):
"""Handle representation of VeSync Light Switch."""
- def __init__(self, switch):
+ def __init__(
+ self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator
+ ) -> None:
"""Initialize Light Switch device class."""
- super().__init__(switch)
+ super().__init__(switch, coordinator)
self.switch = switch
diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py
index cb652270c69..4a75f5cccd2 100644
--- a/homeassistant/components/viaggiatreno/sensor.py
+++ b/homeassistant/components/viaggiatreno/sensor.py
@@ -16,8 +16,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py
index 9c331f0e9ec..12d8ba520f1 100644
--- a/homeassistant/components/vicare/__init__.py
+++ b/homeassistant/components/vicare/__init__.py
@@ -146,7 +146,8 @@ async def async_migrate_devices_and_entities(
# to `-heating-`
if entity_entry.domain == DOMAIN_CLIMATE:
unique_id_parts[len(unique_id_parts) - 1] = (
- f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}"
+ f"{entity_entry.translation_key}-"
+ f"{unique_id_parts[len(unique_id_parts) - 1]}"
)
entity_new_unique_id = "-".join(unique_id_parts)
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index 62231a4e2fe..f62fdc363a6 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -32,8 +32,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py
index 6594e6ec9e4..c1d4adda62a 100644
--- a/homeassistant/components/vicare/config_flow.py
+++ b/homeassistant/components/vicare/config_flow.py
@@ -12,11 +12,11 @@ from PyViCare.PyViCareUtils import (
)
import voluptuous as vol
-from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_HEATING_TYPE,
@@ -109,7 +109,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Invoke when a Viessmann MAC address is discovered on the network."""
formatted_mac = format_mac(discovery_info.macaddress)
diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py
index 2d858185b9f..11955a94b94 100644
--- a/homeassistant/components/vicare/entity.py
+++ b/homeassistant/components/vicare/entity.py
@@ -29,7 +29,11 @@ class ViCareEntity(Entity):
gateway_serial = device_config.getConfig().serial
device_id = device_config.getId()
- identifier = f"{gateway_serial}_{device_serial.replace("zigbee-", "zigbee_") if device_serial is not None else device_id}"
+ identifier = (
+ f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}"
+ if device_serial is not None
+ else f"{gateway_serial}_{device_id}"
+ )
self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = (
component if component else device
diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py
index 69aa8396fea..190a893157c 100644
--- a/homeassistant/components/vicare/fan.py
+++ b/homeassistant/components/vicare/fan.py
@@ -13,9 +13,6 @@ from PyViCare.PyViCareUtils import (
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
-from PyViCare.PyViCareVentilationDevice import (
- VentilationDevice as PyViCareVentilationDevice,
-)
from requests.exceptions import ConnectionError as RequestConnectionError
from homeassistant.components.fan import FanEntity, FanEntityFeature
@@ -28,7 +25,7 @@ from homeassistant.util.percentage import (
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice
-from .utils import get_device_serial
+from .utils import filter_state, get_device_serial
_LOGGER = logging.getLogger(__name__)
@@ -50,6 +47,8 @@ class VentilationMode(enum.StrEnum):
PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour)
VENTILATION = "ventilation" # activated by schedule
+ STANDBY = "standby" # activated by schedule
+ STANDARD = "standard" # activated by schedule
SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor
SENSOR_OVERRIDE = "sensor_override" # activated by sensor
@@ -77,6 +76,8 @@ class VentilationMode(enum.StrEnum):
HA_TO_VICARE_MODE_VENTILATION = {
VentilationMode.PERMANENT: "permanent",
VentilationMode.VENTILATION: "ventilation",
+ VentilationMode.STANDBY: "standby",
+ VentilationMode.STANDARD: "standard",
VentilationMode.SENSOR_DRIVEN: "sensorDriven",
VentilationMode.SENSOR_OVERRIDE: "sensorOverride",
}
@@ -96,7 +97,7 @@ def _build_entities(
return [
ViCareFan(get_device_serial(device.api), device.config, device.api)
for device in device_list
- if isinstance(device.api, PyViCareVentilationDevice)
+ if device.api.isVentilationDevice()
]
@@ -118,7 +119,6 @@ class ViCareFan(ViCareEntity, FanEntity):
"""Representation of the ViCare ventilation device."""
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
- _attr_supported_features = FanEntityFeature.SET_SPEED
_attr_translation_key = "ventilation"
def __init__(
@@ -131,8 +131,8 @@ class ViCareFan(ViCareEntity, FanEntity):
super().__init__(
self._attr_translation_key, device_serial, device_config, device
)
- # init presets
- supported_modes = list[str](self._api.getAvailableModes())
+ # init preset_mode
+ supported_modes = list[str](self._api.getVentilationModes())
self._attr_preset_modes = [
mode
for mode in VentilationMode
@@ -140,18 +140,29 @@ class ViCareFan(ViCareEntity, FanEntity):
]
if len(self._attr_preset_modes) > 0:
self._attr_supported_features |= FanEntityFeature.PRESET_MODE
+ # init set_speed
+ supported_levels: list[str] | None = None
+ with suppress(PyViCareNotSupportedFeatureError):
+ supported_levels = self._api.getVentilationLevels()
+ if supported_levels is not None and len(supported_levels) > 0:
+ self._attr_supported_features |= FanEntityFeature.SET_SPEED
def update(self) -> None:
"""Update state of fan."""
+ level: str | None = None
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_preset_mode = VentilationMode.from_vicare_mode(
- self._api.getActiveMode()
+ self._api.getActiveVentilationMode()
)
with suppress(PyViCareNotSupportedFeatureError):
+ level = filter_state(self._api.getVentilationLevel())
+ if level is not None and level in ORDERED_NAMED_FAN_SPEEDS:
self._attr_percentage = ordered_list_item_to_percentage(
- ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram()
+ ORDERED_NAMED_FAN_SPEEDS, VentilationProgram(level)
)
+ else:
+ self._attr_percentage = 0
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
@@ -198,10 +209,10 @@ class ViCareFan(ViCareEntity, FanEntity):
level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
_LOGGER.debug("changing ventilation level to %s", level)
- self._api.setPermanentLevel(level)
+ self._api.setVentilationLevel(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)
+ self._api.activateVentilationMode(target_mode)
diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json
index 9d0f27a863c..52148b1fa32 100644
--- a/homeassistant/components/vicare/icons.json
+++ b/homeassistant/components/vicare/icons.json
@@ -84,6 +84,9 @@
},
"compressor_phase": {
"default": "mdi:information"
+ },
+ "ventilation_level": {
+ "default": "mdi:fan"
}
}
},
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 98ff6ce4c82..766cf22cb94 100644
--- a/homeassistant/components/vicare/manifest.json
+++ b/homeassistant/components/vicare/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/vicare",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
- "requirements": ["PyViCare==2.39.1"]
+ "requirements": ["PyViCare==2.41.0"]
}
diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml
index 35a1e7b0adb..55b7590a092 100644
--- a/homeassistant/components/vicare/quality_scale.yaml
+++ b/homeassistant/components/vicare/quality_scale.yaml
@@ -23,9 +23,7 @@ rules:
comment: No coordinator is used, data update is centrally handled by the library.
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions:
- status: todo
- comment: removal instructions missing
+ docs-removal-instructions: done
docs-actions: done
brands: done
# Silver
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index 3386c849f74..091deeba2a9 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -30,6 +30,7 @@ from homeassistant.const import (
EntityCategory,
UnitOfEnergy,
UnitOfPower,
+ UnitOfPressure,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
@@ -49,6 +50,7 @@ from .const import (
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin
from .utils import (
+ filter_state,
get_burners,
get_circuits,
get_compressors,
@@ -796,7 +798,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="photovoltaic_status",
device_class=SensorDeviceClass.ENUM,
options=["ready", "production"],
- value_getter=lambda api: _filter_pv_states(api.getPhotovoltaicStatus()),
+ value_getter=lambda api: filter_state(api.getPhotovoltaicStatus()),
),
ViCareSensorEntityDescription(
key="room_temperature",
@@ -812,6 +814,75 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getHumidity(),
),
+ ViCareSensorEntityDescription(
+ key="ventilation_level",
+ translation_key="ventilation_level",
+ value_getter=lambda api: filter_state(api.getVentilationLevel().lower()),
+ device_class=SensorDeviceClass.ENUM,
+ options=["standby", "levelone", "leveltwo", "levelthree", "levelfour"],
+ ),
+ ViCareSensorEntityDescription(
+ key="ventilation_reason",
+ translation_key="ventilation_reason",
+ value_getter=lambda api: api.getVentilationReason().lower(),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "standby",
+ "permanent",
+ "schedule",
+ "sensordriven",
+ "silent",
+ "forcedlevelfour",
+ ],
+ ),
+ ViCareSensorEntityDescription(
+ key="supply_pressure",
+ translation_key="supply_pressure",
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.BAR,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_getter=lambda api: api.getSupplyPressure(),
+ unit_getter=lambda api: api.getSupplyPressureUnit(),
+ ),
+ ViCareSensorEntityDescription(
+ key="heating_rod_starts",
+ translation_key="heating_rod_starts",
+ value_getter=lambda api: api.getHeatingRodStarts(),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ ViCareSensorEntityDescription(
+ key="heating_rod_hours",
+ translation_key="heating_rod_hours",
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ value_getter=lambda api: api.getHeatingRodHours(),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ ViCareSensorEntityDescription(
+ key="spf_total",
+ translation_key="spf_total",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_getter=lambda api: api.getSeasonalPerformanceFactorTotal(),
+ ),
+ ViCareSensorEntityDescription(
+ key="spf_dhw",
+ translation_key="spf_dhw",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_getter=lambda api: api.getSeasonalPerformanceFactorDHW(),
+ ),
+ ViCareSensorEntityDescription(
+ key="spf_heating",
+ translation_key="spf_heating",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(),
+ ),
)
CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
@@ -920,10 +991,6 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
)
-def _filter_pv_states(state: str) -> str | None:
- return None if state in ("nothing", "unknown") else state
-
-
def _build_entities(
device_list: list[ViCareDevice],
) -> list[ViCareSensor]:
diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json
index 4934507e41c..26ca0f5a264 100644
--- a/homeassistant/components/vicare/strings.json
+++ b/homeassistant/components/vicare/strings.json
@@ -81,10 +81,12 @@
"state_attributes": {
"preset_mode": {
"state": {
- "permanent": "permanent",
- "ventilation": "schedule",
- "sensor_driven": "sensor",
- "sensor_override": "schedule with sensor-override"
+ "standby": "[%key:common::state::standby%]",
+ "permanent": "Permanent",
+ "ventilation": "Schedule",
+ "sensor_driven": "Sensor-driven",
+ "sensor_override": "Schedule with sensor-override",
+ "standard": "Minimal"
}
}
}
@@ -375,25 +377,25 @@
"name": "Energy export to grid"
},
"photovoltaic_power_production_current": {
- "name": "Solar power"
+ "name": "PV power"
},
"photovoltaic_energy_production_today": {
- "name": "Solar energy production today"
+ "name": "PV energy production today"
},
"photovoltaic_energy_production_this_week": {
- "name": "Solar energy production this week"
+ "name": "PV energy production this week"
},
"photovoltaic_energy_production_this_month": {
- "name": "Solar energy production this month"
+ "name": "PV energy production this month"
},
"photovoltaic_energy_production_this_year": {
- "name": "Solar energy production this year"
+ "name": "PV energy production this year"
},
"photovoltaic_energy_production_total": {
- "name": "Solar energy production total"
+ "name": "PV energy production total"
},
"photovoltaic_status": {
- "name": "Solar state",
+ "name": "PV state",
"state": {
"ready": "Standby",
"production": "Producing"
@@ -434,6 +436,45 @@
},
"compressor_phase": {
"name": "Compressor phase"
+ },
+ "ventilation_level": {
+ "name": "Ventilation level",
+ "state": {
+ "standby": "[%key:common::state::standby%]",
+ "levelone": "1",
+ "leveltwo": "2",
+ "levelthree": "3",
+ "levelfour": "4"
+ }
+ },
+ "ventilation_reason": {
+ "name": "Ventilation reason",
+ "state": {
+ "standby": "[%key:common::state::standby%]",
+ "permanent": "Permanent",
+ "schedule": "Schedule",
+ "sensordriven": "Sensor-driven",
+ "silent": "Silent",
+ "forcedlevelfour": "Boost"
+ }
+ },
+ "supply_pressure": {
+ "name": "Supply pressure"
+ },
+ "heating_rod_starts": {
+ "name": "Heating rod starts"
+ },
+ "heating_rod_hours": {
+ "name": "Heating rod hours"
+ },
+ "spf_total": {
+ "name": "Seasonal performance factor"
+ },
+ "spf_dhw": {
+ "name": "Seasonal performance factor - domestic hot water"
+ },
+ "spf_heating": {
+ "name": "Seasonal performance factor - heating"
}
},
"water_heater": {
diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py
index 120dad83113..a2c31df4259 100644
--- a/homeassistant/components/vicare/utils.py
+++ b/homeassistant/components/vicare/utils.py
@@ -128,3 +128,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
except AttributeError as error:
_LOGGER.debug("No compressors found: %s", error)
return []
+
+
+def filter_state(state: str) -> str | None:
+ """Remove invalid states."""
+ return None if state in ("nothing", "unknown") else state
diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py
index d3921061d8e..572f093dfd3 100644
--- a/homeassistant/components/vizio/config_flow.py
+++ b/homeassistant/components/vizio/config_flow.py
@@ -11,7 +11,6 @@ from pyvizio import VizioAsync, async_guess_device_type
from pyvizio.const import APP_HOME
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.config_entries import (
SOURCE_ZEROCONF,
@@ -32,6 +31,7 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.network import is_ip_address
from .const import (
@@ -257,7 +257,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
host = discovery_info.host
diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py
index 8451ae747de..fbfaf222cad 100644
--- a/homeassistant/components/vizio/const.py
+++ b/homeassistant/components/vizio/const.py
@@ -10,7 +10,7 @@ from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntityFeature,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import VolDictType
SERVICE_UPDATE_SETTING = "update_setting"
diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json
index 6091cd72f3f..2f97bb332e8 100644
--- a/homeassistant/components/vizio/strings.json
+++ b/homeassistant/components/vizio/strings.json
@@ -6,7 +6,7 @@
"data": {
"name": "[%key:common::config_flow::data::name%]",
"host": "[%key:common::config_flow::data::host%]",
- "device_class": "Device Type",
+ "device_class": "Device type",
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
@@ -14,25 +14,25 @@
}
},
"pair_tv": {
- "title": "Complete Pairing Process",
+ "title": "Complete pairing process",
"description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
},
"pairing_complete": {
- "title": "Pairing Complete",
- "description": "Your VIZIO SmartCast Device is now connected to Home Assistant."
+ "title": "Pairing complete",
+ "description": "Your VIZIO SmartCast device is now connected to Home Assistant."
},
"pairing_complete_import": {
"title": "[%key:component::vizio::config::step::pairing_complete::title%]",
- "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'."
+ "description": "Your VIZIO SmartCast device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'."
}
},
"error": {
"complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one."
+ "existing_config_entry_found": "An existing VIZIO SmartCast device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one."
},
"abort": {
"already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -43,12 +43,12 @@
"options": {
"step": {
"init": {
- "title": "Update VIZIO SmartCast Device Options",
+ "title": "Update VIZIO SmartCast device options",
"description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.",
"data": {
- "volume_step": "Volume Step Size",
- "include_or_exclude": "Include or Exclude Apps?",
- "apps_to_include_or_exclude": "Apps to Include or Exclude"
+ "volume_step": "Volume step size",
+ "include_or_exclude": "Include or exclude apps?",
+ "apps_to_include_or_exclude": "Apps to include or exclude"
}
}
}
diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py
index cd05c919d58..d1a481a99b1 100644
--- a/homeassistant/components/vlc/media_player.py
+++ b/homeassistant/components/vlc/media_player.py
@@ -20,10 +20,10 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py
index b95e987aef8..9597c706570 100644
--- a/homeassistant/components/vlc_telnet/media_player.py
+++ b/homeassistant/components/vlc_telnet/media_player.py
@@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import VlcConfigEntry
from .const import DEFAULT_NAME, DOMAIN, LOGGER
diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py
index 14cfaabdf7a..99f953d50d5 100644
--- a/homeassistant/components/vodafone_station/const.py
+++ b/homeassistant/components/vodafone_station/const.py
@@ -5,6 +5,7 @@ import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "vodafone_station"
+SCAN_INTERVAL = 30
DEFAULT_DEVICE_NAME = "Unknown device"
DEFAULT_HOST = "192.168.1.1"
diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py
index e95ca2b5976..de794488040 100644
--- a/homeassistant/components/vodafone_station/coordinator.py
+++ b/homeassistant/components/vodafone_station/coordinator.py
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
-from .const import _LOGGER, DOMAIN
+from .const import _LOGGER, DOMAIN, SCAN_INTERVAL
CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds()
@@ -59,7 +59,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
hass=hass,
logger=_LOGGER,
name=f"{DOMAIN}-{host}-coordinator",
- update_interval=timedelta(seconds=30),
+ update_interval=timedelta(seconds=SCAN_INTERVAL),
)
def _calculate_update_time_and_consider_home(
diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py
index 9f1615ffa01..6bf42d86836 100644
--- a/homeassistant/components/voicerss/tts.py
+++ b/homeassistant/components/voicerss/tts.py
@@ -13,8 +13,8 @@ from homeassistant.components.tts import (
Provider,
)
from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py
index cee0cbb0766..96e758e91f4 100644
--- a/homeassistant/components/voip/__init__.py
+++ b/homeassistant/components/voip/__init__.py
@@ -30,9 +30,9 @@ _IP_WILDCARD = "0.0.0.0"
__all__ = [
"DOMAIN",
+ "async_remove_config_entry_device",
"async_setup_entry",
"async_unload_entry",
- "async_remove_config_entry_device",
]
diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py
index 0100435d6dc..1877b8c655c 100644
--- a/homeassistant/components/voip/assist_satellite.py
+++ b/homeassistant/components/voip/assist_satellite.py
@@ -8,23 +8,29 @@ from functools import partial
import io
import logging
from pathlib import Path
+import socket
+import time
from typing import TYPE_CHECKING, Any, Final
import wave
-from voip_utils import RtpDatagramProtocol
+from voip_utils import SIP_PORT, RtpDatagramProtocol
+from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint
from homeassistant.components import tts
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
from homeassistant.components.assist_satellite import (
+ AssistSatelliteAnnouncement,
AssistSatelliteConfiguration,
AssistSatelliteEntity,
AssistSatelliteEntityDescription,
+ AssistSatelliteEntityFeature,
)
+from homeassistant.components.network import async_get_source_ip
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH
+from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH
from .devices import VoIPDevice
from .entity import VoIPEntity
@@ -34,6 +40,10 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_PIPELINE_TIMEOUT_SEC: Final = 30
+_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5
+_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0
+_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5
+_ANNOUNCEMENT_RING_TIMEOUT: Final = 30
class Tones(IntFlag):
@@ -80,6 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
entity_description = AssistSatelliteEntityDescription(key="assist_satellite")
_attr_translation_key = "assist_satellite"
_attr_name = None
+ _attr_supported_features = (
+ AssistSatelliteEntityFeature.ANNOUNCE
+ | AssistSatelliteEntityFeature.START_CONVERSATION
+ )
def __init__(
self,
@@ -105,6 +119,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self._tones = tones
self._processing_tone_done = asyncio.Event()
+ self._announcement: AssistSatelliteAnnouncement | None = None
+ self._announcement_future: asyncio.Future[Any] = asyncio.Future()
+ self._announcment_start_time: float = 0.0
+ self._check_announcement_ended_task: asyncio.Task | None = None
+ self._last_chunk_time: float | None = None
+ self._rtp_port: int | None = None
+ self._run_pipeline_after_announce: bool = False
+
@property
def pipeline_entity_id(self) -> str | None:
"""Return the entity ID of the pipeline to use for the next conversation."""
@@ -149,25 +171,146 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
"""Set the current satellite configuration."""
raise NotImplementedError
+ async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None:
+ """Announce media on the satellite.
+
+ Plays announcement in a loop, blocking until the caller hangs up.
+ """
+ await self._do_announce(announcement, run_pipeline_after=False)
+
+ async def _do_announce(
+ self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool
+ ) -> None:
+ """Announce media on the satellite.
+
+ Optionally run a voice pipeline after the announcement has finished.
+ """
+ self._announcement_future = asyncio.Future()
+ self._run_pipeline_after_announce = run_pipeline_after
+
+ if self._rtp_port is None:
+ # Choose random port for RTP
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setblocking(False)
+ sock.bind(("", 0))
+ _rtp_ip, self._rtp_port = sock.getsockname()
+ sock.close()
+
+ # HA SIP server
+ source_ip = await async_get_source_ip(self.hass)
+ sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT)
+ source_endpoint = get_sip_endpoint(host=source_ip, port=sip_port)
+
+ try:
+ # VoIP ID is SIP header
+ destination_endpoint = SipEndpoint(self.voip_device.voip_id)
+ except ValueError:
+ # VoIP ID is IP address
+ destination_endpoint = get_sip_endpoint(
+ host=self.voip_device.voip_id, port=SIP_PORT
+ )
+
+ # Reset state so we can time out if needed
+ self._last_chunk_time = None
+ self._announcment_start_time = time.monotonic()
+ self._announcement = announcement
+
+ # Make the call
+ sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol
+ call_info = sip_protocol.outgoing_call(
+ source=source_endpoint,
+ destination=destination_endpoint,
+ rtp_port=self._rtp_port,
+ )
+
+ # Check if caller hung up or didn't pick up
+ self._check_announcement_ended_task = (
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self._check_announcement_ended(),
+ "voip_announcement_ended",
+ )
+ )
+
+ try:
+ await self._announcement_future
+ except TimeoutError:
+ # Stop ringing
+ sip_protocol.cancel_call(call_info)
+ raise
+
+ async def _check_announcement_ended(self) -> None:
+ """Continuously checks if an audio chunk was received within a time limit.
+
+ If not, the caller is presumed to have hung up and the announcement is ended.
+ """
+ while self._announcement is not None:
+ current_time = time.monotonic()
+ if (self._last_chunk_time is None) and (
+ (current_time - self._announcment_start_time)
+ > _ANNOUNCEMENT_RING_TIMEOUT
+ ):
+ # Ring timeout
+ self._announcement = None
+ self._check_announcement_ended_task = None
+ self._announcement_future.set_exception(
+ TimeoutError("User did not pick up in time")
+ )
+ _LOGGER.debug("Timed out waiting for the user to pick up the phone")
+ break
+
+ if (self._last_chunk_time is not None) and (
+ (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
+ ):
+ # Caller hung up
+ self._announcement = None
+ self._announcement_future.set_result(None)
+ self._check_announcement_ended_task = None
+ _LOGGER.debug("Announcement ended")
+ break
+
+ await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2)
+
+ async def async_start_conversation(
+ self, start_announcement: AssistSatelliteAnnouncement
+ ) -> None:
+ """Start a conversation from the satellite."""
+ await self._do_announce(start_announcement, run_pipeline_after=True)
+
# -------------------------------------------------------------------------
# VoIP
# -------------------------------------------------------------------------
def on_chunk(self, audio_bytes: bytes) -> None:
"""Handle raw audio chunk."""
- if self._run_pipeline_task is None:
- # Run pipeline until voice command finishes, then start over
- self._clear_audio_queue()
- self._tts_done.clear()
+ self._last_chunk_time = time.monotonic()
+
+ if self._announcement is None:
+ # Pipeline with STT
+ if self._run_pipeline_task is None:
+ # Run pipeline until voice command finishes, then start over
+ self._clear_audio_queue()
+ self._tts_done.clear()
+ self._run_pipeline_task = (
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self._run_pipeline(),
+ "voip_pipeline_run",
+ )
+ )
+
+ self._audio_queue.put_nowait(audio_bytes)
+ elif self._run_pipeline_task is None:
+ # Announcement only
+ # Play announcement (will repeat)
self._run_pipeline_task = self.config_entry.async_create_background_task(
self.hass,
- self._run_pipeline(),
- "voip_pipeline_run",
+ self._play_announcement(self._announcement),
+ "voip_play_announcement",
)
- self._audio_queue.put_nowait(audio_bytes)
-
async def _run_pipeline(self) -> None:
+ """Run a pipeline with STT input and TTS output."""
_LOGGER.debug("Starting pipeline")
self.async_set_context(Context(user_id=self.config_entry.data["user"]))
@@ -209,6 +352,31 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self._run_pipeline_task = None
_LOGGER.debug("Pipeline finished")
+ async def _play_announcement(
+ self, announcement: AssistSatelliteAnnouncement
+ ) -> None:
+ """Play an announcement once."""
+ _LOGGER.debug("Playing announcement")
+
+ try:
+ await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY)
+ await self._send_tts(announcement.original_media_id, wait_for_tone=False)
+
+ if not self._run_pipeline_after_announce:
+ # Delay before looping announcement
+ await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY)
+ except Exception:
+ _LOGGER.exception("Unexpected error while playing announcement")
+ raise
+ finally:
+ self._run_pipeline_task = None
+ _LOGGER.debug("Announcement finished")
+
+ if self._run_pipeline_after_announce:
+ # Clear announcement to allow pipeline to run
+ self._announcement = None
+ self._announcement_future.set_result(None)
+
def _clear_audio_queue(self) -> None:
"""Ensure audio queue is empty."""
while not self._audio_queue.empty():
@@ -239,7 +407,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self._pipeline_had_error = True
_LOGGER.warning(event)
- async def _send_tts(self, media_id: str) -> None:
+ async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None:
"""Send TTS audio to caller via RTP."""
try:
if self.transport is None:
@@ -253,7 +421,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
if extension != "wav":
raise ValueError(f"Only WAV audio can be streamed, got {extension}")
- if (self._tones & Tones.PROCESSING) == Tones.PROCESSING:
+ if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING):
# Don't overlap TTS and processing beep
_LOGGER.debug("Waiting for processing tone")
await self._processing_tone_done.wait()
diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py
index 613d05fc614..c33ec048cbd 100644
--- a/homeassistant/components/voip/devices.py
+++ b/homeassistant/components/voip/devices.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
+from typing import Any
from voip_utils import CallInfo, VoipDatagramProtocol
@@ -136,25 +137,56 @@ class VoIPDevices:
fw_version = None
dev_reg = dr.async_get(self.hass)
- voip_id = call_info.caller_ip
+ if call_info.caller_endpoint is None:
+ raise RuntimeError("Could not identify VOIP caller")
+ voip_id = call_info.caller_endpoint.uri
voip_device = self.devices.get(voip_id)
- if voip_device is not None:
- device = dev_reg.async_get(voip_device.device_id)
- if device and fw_version and device.sw_version != fw_version:
- dev_reg.async_update_device(device.id, sw_version=fw_version)
+ if voip_device is None:
+ # If we couldn't find the device based on SIP URI, see if we can
+ # find an old device based on just the host/IP and migrate it
+ old_id = call_info.caller_endpoint.host
+ voip_device = self.devices.get(old_id)
+ if voip_device is not None:
+ voip_device.voip_id = voip_id
+ self.devices[voip_id] = voip_device
+ dev_reg.async_update_device(
+ voip_device.device_id, new_identifiers={(DOMAIN, voip_id)}
+ )
+ # Migrate entities
+ old_prefix = f"{old_id}-"
- return voip_device
+ def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None:
+ """Migrate entities."""
+ if not entry.unique_id.startswith(old_prefix):
+ return None
+ key = entry.unique_id[len(old_prefix) :]
+ return {
+ "new_unique_id": f"{voip_id}-{key}",
+ }
+ self.config_entry.async_create_task(
+ self.hass,
+ er.async_migrate_entries(
+ self.hass, self.config_entry.entry_id, entity_migrator
+ ),
+ f"voip migrating entities {voip_id}",
+ )
+
+ # Update device with latest info
device = dev_reg.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, voip_id)},
- name=voip_id,
+ name=call_info.caller_endpoint.host,
manufacturer=manuf,
model=model,
sw_version=fw_version,
configuration_url=f"http://{call_info.caller_ip}",
)
+
+ if voip_device is not None:
+ return voip_device
+
voip_device = self.devices[voip_id] = VoIPDevice(
voip_id=voip_id,
device_id=device.id,
diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json
index ed7f11f8fbc..e3b2861dbe5 100644
--- a/homeassistant/components/voip/manifest.json
+++ b/homeassistant/components/voip/manifest.json
@@ -3,9 +3,9 @@
"name": "Voice over IP",
"codeowners": ["@balloob", "@synesthesiam"],
"config_flow": true,
- "dependencies": ["assist_pipeline", "assist_satellite"],
+ "dependencies": ["assist_pipeline", "assist_satellite", "network"],
"documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["voip-utils==0.2.2"]
+ "requirements": ["voip-utils==0.3.1"]
}
diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py
index c4fa7b1088b..5bd4a63c923 100644
--- a/homeassistant/components/volkszaehler/sensor.py
+++ b/homeassistant/components/volkszaehler/sensor.py
@@ -26,8 +26,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py
index 7cc58556f3e..00b3ab911ae 100644
--- a/homeassistant/components/volumio/config_flow.py
+++ b/homeassistant/components/volumio/config_flow.py
@@ -8,12 +8,12 @@ from typing import Any
from pyvolumio import CannotConnectError, Volumio
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -97,7 +97,7 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self._host = discovery_info.host
diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json
index 61b5a954389..d8344cbdeec 100644
--- a/homeassistant/components/vulcan/strings.json
+++ b/homeassistant/components/vulcan/strings.json
@@ -4,7 +4,7 @@
"already_configured": "That student has already been added.",
"all_student_already_configured": "All students have already been added.",
"reauth_successful": "Reauth successful",
- "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student."
+ "no_matching_entries": "No matching entries found, please use different account or remove outdated student integration."
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
@@ -17,7 +17,7 @@
},
"step": {
"auth": {
- "description": "Login to your Vulcan Account using mobile app registration page.",
+ "description": "Log in to your Vulcan Account using mobile app registration page.",
"data": {
"token": "Token",
"region": "Symbol",
diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py
index 36f43cf0ac0..66527bf458e 100644
--- a/homeassistant/components/vultr/__init__.py
+++ b/homeassistant/components/vultr/__init__.py
@@ -9,7 +9,7 @@ from vultr import Vultr as VultrAPI
from homeassistant.components import persistent_notification
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py
index 6a697eebe11..3972de8a625 100644
--- a/homeassistant/components/vultr/binary_sensor.py
+++ b/homeassistant/components/vultr/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py
index 843aa416297..c392c382cbd 100644
--- a/homeassistant/components/vultr/sensor.py
+++ b/homeassistant/components/vultr/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py
index b03d613895a..0b1f2247684 100644
--- a/homeassistant/components/vultr/switch.py
+++ b/homeassistant/components/vultr/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py
index 62b9ba810d9..7dab0b137c5 100644
--- a/homeassistant/components/w800rf32/__init__.py
+++ b/homeassistant/components/w800rf32/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py
index efd72c4564c..d68d950e641 100644
--- a/homeassistant/components/wake_on_lan/__init__.py
+++ b/homeassistant/components/wake_on_lan/__init__.py
@@ -9,7 +9,7 @@ import wakeonlan
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py
index fcf8936d498..16df34c1d1b 100644
--- a/homeassistant/components/wake_on_lan/switch.py
+++ b/homeassistant/components/wake_on_lan/switch.py
@@ -21,8 +21,7 @@ from homeassistant.const import (
CONF_NAME,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py
index 8b3a5bbf331..65556668bac 100644
--- a/homeassistant/components/wake_word/__init__.py
+++ b/homeassistant/components/wake_word/__init__.py
@@ -25,12 +25,12 @@ from .const import DOMAIN
from .models import DetectionResult, WakeWord
__all__ = [
- "async_default_entity",
- "async_get_wake_word_detection_entity",
- "DetectionResult",
"DOMAIN",
+ "DetectionResult",
"WakeWord",
"WakeWordDetectionEntity",
+ "async_default_entity",
+ "async_get_wake_word_detection_entity",
]
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py
index 60be340a253..c9155950680 100644
--- a/homeassistant/components/water_heater/__init__.py
+++ b/homeassistant/components/water_heater/__init__.py
@@ -8,7 +8,7 @@ import functools as ft
import logging
from typing import Any, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -25,7 +25,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import deprecated_class
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstant,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.temperature import display_temp as show_temp
@@ -134,11 +139,11 @@ class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes water heater entities."""
-@deprecated_class("WaterHeaterEntityDescription", breaks_in_ha_version="2026.1")
-class WaterHeaterEntityEntityDescription(
- WaterHeaterEntityDescription, frozen_or_thawed=True
-):
- """A (deprecated) class that describes water heater entities."""
+_DEPRECATED_WaterHeaterEntityEntityDescription = DeprecatedConstant(
+ WaterHeaterEntityDescription,
+ "WaterHeaterEntityDescription",
+ breaks_in_ha_version="2026.1",
+)
CACHED_PROPERTIES_WITH_ATTR_ = {
@@ -414,3 +419,11 @@ async def async_service_temperature_set(
kwargs[value] = temp
await entity.async_set_temperature(**kwargs)
+
+
+# These can be removed if no deprecated constant are in this module anymore
+__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
+__dir__ = ft.partial(
+ dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
+)
+__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py
index 49cfc7e9a07..d68919ff8f3 100644
--- a/homeassistant/components/water_heater/device_action.py
+++ b/homeassistant/components/water_heater/device_action.py
@@ -15,8 +15,7 @@ from homeassistant.const import (
SERVICE_TURN_ON,
)
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DOMAIN
diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py
index de8c85f5ff0..0130b53930b 100644
--- a/homeassistant/components/watson_iot/__init__.py
+++ b/homeassistant/components/watson_iot/__init__.py
@@ -23,8 +23,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py
index 373d17438c9..194e0905ff0 100644
--- a/homeassistant/components/watson_tts/tts.py
+++ b/homeassistant/components/watson_tts/tts.py
@@ -10,7 +10,7 @@ from homeassistant.components.tts import (
PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
Provider,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py
index 557765795ee..e9436922a4b 100644
--- a/homeassistant/components/weather/__init__.py
+++ b/homeassistant/components/weather/__init__.py
@@ -8,10 +8,19 @@ from contextlib import suppress
from datetime import timedelta
from functools import partial
import logging
-from typing import Any, Final, Generic, Literal, Required, TypedDict, cast, final
+from typing import (
+ Any,
+ Final,
+ Generic,
+ Literal,
+ Required,
+ TypedDict,
+ TypeVar,
+ cast,
+ final,
+)
-from propcache import cached_property
-from typing_extensions import TypeVar
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json
index f707cbb0353..d22c62a030c 100644
--- a/homeassistant/components/weatherflow_cloud/strings.json
+++ b/homeassistant/components/weatherflow_cloud/strings.json
@@ -4,7 +4,7 @@
"user": {
"description": "Set up a WeatherFlow Forecast Station",
"data": {
- "api_token": "Personal api token"
+ "api_token": "Personal API token"
}
},
"reauth_confirm": {
diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py
index 34e11f49978..92ef59db908 100644
--- a/homeassistant/components/webhook/__init__.py
+++ b/homeassistant/components/webhook/__init__.py
@@ -21,7 +21,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.network import get_url, is_cloud_connection
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
-from homeassistant.util import network
+from homeassistant.util import network as network_util
from homeassistant.util.aiohttp import MockRequest, MockStreamReader, serialize_response
_LOGGER = logging.getLogger(__name__)
@@ -97,14 +97,16 @@ def async_generate_url(
) -> str:
"""Generate the full URL for a webhook_id."""
return (
- f"{get_url(
- hass,
- allow_internal=allow_internal,
- allow_external=allow_external,
- allow_cloud=False,
- allow_ip=allow_ip,
- prefer_external=prefer_external,
- )}"
+ f"{
+ get_url(
+ hass,
+ allow_internal=allow_internal,
+ allow_external=allow_external,
+ allow_cloud=False,
+ allow_ip=allow_ip,
+ prefer_external=prefer_external,
+ )
+ }"
f"{async_generate_path(webhook_id)}"
)
@@ -172,7 +174,7 @@ async def async_handle_webhook(
_LOGGER.debug("Unable to parse remote ip %s", request.remote)
return Response(status=HTTPStatus.OK)
- is_local = network.is_local(request_remote)
+ is_local = network_util.is_local(request_remote)
if not is_local:
_LOGGER.warning("Received remote request for local webhook %s", webhook_id)
diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py
index b4fd3008cd8..907123561f7 100644
--- a/homeassistant/components/webhook/trigger.py
+++ b/homeassistant/components/webhook/trigger.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py
index 64f8c684dfa..903d6c50a09 100644
--- a/homeassistant/components/webmin/config_flow.py
+++ b/homeassistant/components/webmin/config_flow.py
@@ -45,7 +45,7 @@ async def validate_user_input(
raise SchemaFlowError("invalid_auth") from err
raise SchemaFlowError("cannot_connect") from err
except Fault as fault:
- LOGGER.exception(f"Fault {fault.faultCode}: {fault.faultString}")
+ LOGGER.exception("Fault %s: %s", fault.faultCode, fault.faultString)
raise SchemaFlowError("unknown") from fault
except ClientConnectionError as err:
raise SchemaFlowError("cannot_connect") from err
diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py
index 499d0a85518..c1a1c698f92 100644
--- a/homeassistant/components/webostv/__init__.py
+++ b/homeassistant/components/webostv/__init__.py
@@ -1,95 +1,53 @@
-"""Support for LG webOS Smart TV."""
+"""The LG webOS TV integration."""
from __future__ import annotations
from contextlib import suppress
-import logging
-from typing import NamedTuple
from aiowebostv import WebOsClient, WebOsTvPairError
-import voluptuous as vol
from homeassistant.components import notify as hass_notify
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_COMMAND,
- ATTR_ENTITY_ID,
CONF_CLIENT_SECRET,
CONF_HOST,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
+ Platform,
)
-from homeassistant.core import Event, HomeAssistant, ServiceCall
+from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import config_validation as cv, discovery
-from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import (
- ATTR_BUTTON,
ATTR_CONFIG_ENTRY_ID,
- ATTR_PAYLOAD,
- ATTR_SOUND_OUTPUT,
- DATA_CONFIG_ENTRY,
DATA_HASS_CONFIG,
DOMAIN,
PLATFORMS,
- SERVICE_BUTTON,
- SERVICE_COMMAND,
- SERVICE_SELECT_SOUND_OUTPUT,
WEBOSTV_EXCEPTIONS,
)
+from .helpers import WebOsTvConfigEntry, update_client_key
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids})
-
-
-class ServiceMethodDetails(NamedTuple):
- """Details for SERVICE_TO_METHOD mapping."""
-
- method: str
- schema: vol.Schema
-
-
-BUTTON_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_BUTTON): cv.string})
-
-COMMAND_SCHEMA = CALL_SCHEMA.extend(
- {vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_PAYLOAD): dict}
-)
-
-SOUND_OUTPUT_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_SOUND_OUTPUT): cv.string})
-
-SERVICE_TO_METHOD = {
- SERVICE_BUTTON: ServiceMethodDetails(method="async_button", schema=BUTTON_SCHEMA),
- SERVICE_COMMAND: ServiceMethodDetails(
- method="async_command", schema=COMMAND_SCHEMA
- ),
- SERVICE_SELECT_SOUND_OUTPUT: ServiceMethodDetails(
- method="async_select_sound_output",
- schema=SOUND_OUTPUT_SCHEMA,
- ),
-}
-
-_LOGGER = logging.getLogger(__name__)
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the LG WebOS TV platform."""
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {})
- hass.data[DOMAIN][DATA_HASS_CONFIG] = config
+ """Set up the LG webOS TV platform."""
+ hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config})
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool:
"""Set the config entry up."""
host = entry.data[CONF_HOST]
key = entry.data[CONF_CLIENT_SECRET]
# Attempt a connection, but fail gracefully if tv is off for example.
- client = WebOsClient(host, key)
+ entry.runtime_data = client = WebOsClient(
+ host, key, client_session=async_get_clientsession(hass)
+ )
with suppress(*WEBOSTV_EXCEPTIONS):
try:
await client.connect()
@@ -98,20 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# If pairing request accepted there will be no error
# Update the stored key without triggering reauth
- update_client_key(hass, entry, client)
+ update_client_key(hass, entry)
- async def async_service_handler(service: ServiceCall) -> None:
- method = SERVICE_TO_METHOD[service.service]
- data = service.data.copy()
- data["method"] = method.method
- async_dispatcher_send(hass, DOMAIN, data)
-
- for service, method in SERVICE_TO_METHOD.items():
- hass.services.async_register(
- DOMAIN, service, async_service_handler, schema=method.schema
- )
-
- hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# set up notify platform, no entry support for notify component yet,
@@ -119,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.async_create_task(
discovery.async_load_platform(
hass,
- "notify",
+ Platform.NOTIFY,
DOMAIN,
{
CONF_NAME: entry.title,
@@ -129,8 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
- if not entry.update_listeners:
- entry.async_on_unload(entry.add_update_listener(async_update_options))
+ entry.async_on_unload(entry.add_update_listener(async_update_options))
async def async_on_stop(_event: Event) -> None:
"""Unregister callbacks and disconnect."""
@@ -143,49 +88,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
-async def async_control_connect(host: str, key: str | None) -> WebOsClient:
- """LG Connection."""
- client = WebOsClient(host, key)
- try:
- await client.connect()
- except WebOsTvPairError:
- _LOGGER.warning("Connected to LG webOS TV %s but not paired", host)
- raise
-
- return client
-
-
-def update_client_key(
- hass: HomeAssistant, entry: ConfigEntry, client: WebOsClient
-) -> None:
- """Check and update stored client key if key has changed."""
- host = entry.data[CONF_HOST]
- key = entry.data[CONF_CLIENT_SECRET]
-
- if client.client_key != key:
- _LOGGER.debug("Updating client key for host %s", host)
- data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key}
- hass.config_entries.async_update_entry(entry, data=data)
-
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
- if unload_ok:
- client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id)
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ client = entry.runtime_data
await hass_notify.async_reload(hass, DOMAIN)
client.clear_state_update_callbacks()
await client.disconnect()
- # unregister service calls, check if this is the last entry to unload
- if unload_ok and not hass.data[DOMAIN][DATA_CONFIG_ENTRY]:
- for service in SERVICE_TO_METHOD:
- hass.services.async_remove(DOMAIN, service)
-
return unload_ok
diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py
index 45395bd282a..fbc3eb958dd 100644
--- a/homeassistant/components/webostv/config_flow.py
+++ b/homeassistant/components/webostv/config_flow.py
@@ -1,44 +1,54 @@
-"""Config flow to configure webostv component."""
+"""Config flow for LG webOS TV integration."""
from __future__ import annotations
from collections.abc import Mapping
-import logging
from typing import Any, Self
from urllib.parse import urlparse
-from aiowebostv import WebOsTvPairError
+from aiowebostv import WebOsClient, WebOsTvPairError
import voluptuous as vol
-from homeassistant.components import ssdp
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
-from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME
-from homeassistant.core import callback
-from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
+from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
-from . import async_control_connect, update_client_key
+from . import WebOsTvConfigEntry
from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS
-from .helpers import async_get_sources
+from .helpers import get_sources
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
},
extra=vol.ALLOW_EXTRA,
)
-_LOGGER = logging.getLogger(__name__)
+
+async def async_control_connect(
+ hass: HomeAssistant, host: str, key: str | None
+) -> WebOsClient:
+ """Create LG webOS client and connect to the TV."""
+ client = WebOsClient(
+ host,
+ key,
+ client_session=async_get_clientsession(hass),
+ )
+
+ await client.connect()
+
+ return client
class FlowHandler(ConfigFlow, domain=DOMAIN):
- """WebosTV configuration flow."""
+ """LG webOS TV configuration flow."""
VERSION = 1
@@ -50,7 +60,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
- def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
+ def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
@@ -58,47 +68,26 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
- errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST]
- self._name = user_input[CONF_NAME]
return await self.async_step_pairing()
- return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors
- )
-
- @callback
- def _async_check_configured_entry(self) -> None:
- """Check if entry is configured, update unique_id if needed."""
- for entry in self._async_current_entries(include_ignore=False):
- if entry.data[CONF_HOST] != self._host:
- continue
-
- if self._uuid and not entry.unique_id:
- _LOGGER.debug(
- "Updating unique_id for host %s, unique_id: %s",
- self._host,
- self._uuid,
- )
- self.hass.config_entries.async_update_entry(entry, unique_id=self._uuid)
-
- raise AbortFlow("already_configured")
+ return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
async def async_step_pairing(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Display pairing form."""
- self._async_check_configured_entry()
+ self._async_abort_entries_match({CONF_HOST: self._host})
self.context["title_placeholders"] = {"name": self._name}
- errors = {}
+ errors: dict[str, str] = {}
if user_input is not None:
try:
- client = await async_control_connect(self._host, None)
+ client = await async_control_connect(self.hass, self._host, None)
except WebOsTvPairError:
- return self.async_abort(reason="error_pairing")
+ errors["base"] = "error_pairing"
except WEBOSTV_EXCEPTIONS:
errors["base"] = "cannot_connect"
else:
@@ -107,24 +96,28 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured({CONF_HOST: self._host})
data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key}
+
+ if not self._name:
+ self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}"
return self.async_create_entry(title=self._name, data=data)
return self.async_show_form(step_id="pairing", errors=errors)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
assert discovery_info.ssdp_location
host = urlparse(discovery_info.ssdp_location).hostname
assert host
self._host = host
- self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME)
+ self._name = discovery_info.upnp.get(
+ ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME
+ ).replace("[LG]", "LG")
- uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
+ uuid = discovery_info.upnp[ATTR_UPNP_UDN]
assert uuid
- if uuid.startswith("uuid:"):
- uuid = uuid[5:]
+ uuid = uuid.removeprefix("uuid:")
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured({CONF_HOST: self._host})
@@ -149,26 +142,62 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
+ errors: dict[str, str] = {}
+
if user_input is not None:
try:
- client = await async_control_connect(self._host, None)
+ client = await async_control_connect(self.hass, self._host, None)
except WebOsTvPairError:
- return self.async_abort(reason="error_pairing")
+ errors["base"] = "error_pairing"
except WEBOSTV_EXCEPTIONS:
- return self.async_abort(reason="reauth_unsuccessful")
+ errors["base"] = "cannot_connect"
+ else:
+ reauth_entry = self._get_reauth_entry()
+ data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key}
+ return self.async_update_reload_and_abort(reauth_entry, data=data)
- reauth_entry = self._get_reauth_entry()
- update_client_key(self.hass, reauth_entry, client)
- await self.hass.config_entries.async_reload(reauth_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
+ return self.async_show_form(step_id="reauth_confirm", errors=errors)
- return self.async_show_form(step_id="reauth_confirm")
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+ client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET)
+
+ try:
+ client = await async_control_connect(self.hass, host, client_key)
+ except WebOsTvPairError:
+ errors["base"] = "error_pairing"
+ except WEBOSTV_EXCEPTIONS:
+ errors["base"] = "cannot_connect"
+ else:
+ await self.async_set_unique_id(client.hello_info["deviceUUID"])
+ self._abort_if_unique_id_mismatch(reason="wrong_device")
+ data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key}
+ return self.async_update_reload_and_abort(reconfigure_entry, data=data)
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_HOST, default=reconfigure_entry.data.get(CONF_HOST)
+ ): cv.string
+ }
+ ),
+ errors=errors,
+ )
class OptionsFlowHandler(OptionsFlow):
"""Handle options."""
- def __init__(self, config_entry: ConfigEntry) -> None:
+ def __init__(self, config_entry: WebOsTvConfigEntry) -> None:
"""Initialize options flow."""
self.host = config_entry.data[CONF_HOST]
self.key = config_entry.data[CONF_CLIENT_SECRET]
@@ -182,9 +211,14 @@ class OptionsFlowHandler(OptionsFlow):
options_input = {CONF_SOURCES: user_input[CONF_SOURCES]}
return self.async_create_entry(title="", data=options_input)
# Get sources
- sources_list = await async_get_sources(self.host, self.key)
- if not sources_list:
- errors["base"] = "cannot_retrieve"
+ sources_list = []
+ try:
+ client = await async_control_connect(self.hass, self.host, self.key)
+ sources_list = get_sources(client)
+ except WebOsTvPairError:
+ errors["base"] = "error_pairing"
+ except WEBOSTV_EXCEPTIONS:
+ errors["base"] = "cannot_connect"
option_sources = self.config_entry.options.get(CONF_SOURCES, [])
sources = [s for s in option_sources if s in sources_list]
diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py
index c20060cae91..e505611db52 100644
--- a/homeassistant/components/webostv/const.py
+++ b/homeassistant/components/webostv/const.py
@@ -1,17 +1,16 @@
-"""Constants used for LG webOS Smart TV."""
+"""Constants for the LG webOS TV integration."""
import asyncio
+import aiohttp
from aiowebostv import WebOsTvCommandError
-from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
from homeassistant.const import Platform
DOMAIN = "webostv"
PLATFORMS = [Platform.MEDIA_PLAYER]
-DATA_CONFIG_ENTRY = "config_entry"
DATA_HASS_CONFIG = "hass_config"
-DEFAULT_NAME = "LG webOS Smart TV"
+DEFAULT_NAME = "LG webOS TV"
ATTR_BUTTON = "button"
ATTR_CONFIG_ENTRY_ID = "entry_id"
@@ -28,11 +27,10 @@ SERVICE_SELECT_SOUND_OUTPUT = "select_sound_output"
LIVE_TV_APP_ID = "com.webos.app.livetv"
WEBOSTV_EXCEPTIONS = (
- OSError,
- ConnectionClosed,
- ConnectionClosedOK,
- ConnectionRefusedError,
+ ConnectionResetError,
WebOsTvCommandError,
- TimeoutError,
+ aiohttp.ClientConnectorError,
+ aiohttp.ServerDisconnectedError,
asyncio.CancelledError,
+ asyncio.TimeoutError,
)
diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py
index f16b1cec4f5..951c11525b1 100644
--- a/homeassistant/components/webostv/device_trigger.py
+++ b/homeassistant/components/webostv/device_trigger.py
@@ -1,4 +1,4 @@
-"""Provides device automations for control of LG webOS Smart TV."""
+"""Provides device automations for control of LG webOS TV."""
from __future__ import annotations
@@ -14,8 +14,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-from . import trigger
-from .const import DOMAIN
+from . import DOMAIN, trigger
from .helpers import (
async_get_client_by_device_entry,
async_get_device_entry_by_device_id,
@@ -43,8 +42,7 @@ async def async_validate_trigger_config(
device_id = config[CONF_DEVICE_ID]
try:
device = async_get_device_entry_by_device_id(hass, device_id)
- if DOMAIN in hass.data:
- async_get_client_by_device_entry(hass, device)
+ async_get_client_by_device_entry(hass, device)
except ValueError as err:
raise InvalidDeviceAutomationConfig(err) from err
@@ -77,4 +75,8 @@ async def async_attach_trigger(
hass, trigger_config, action, trigger_info
)
- raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unhandled_trigger_type",
+ translation_placeholders={"trigger_type": trigger_type},
+ )
diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py
index 1657fb71d26..7fb64a2cb8f 100644
--- a/homeassistant/components/webostv/diagnostics.py
+++ b/homeassistant/components/webostv/diagnostics.py
@@ -1,4 +1,4 @@
-"""Diagnostics support for LG webOS Smart TV."""
+"""Diagnostics support for LG webOS TV."""
from __future__ import annotations
@@ -7,11 +7,10 @@ from typing import Any
from aiowebostv import WebOsClient
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
-from .const import DATA_CONFIG_ENTRY, DOMAIN
+from . import WebOsTvConfigEntry
TO_REDACT = {
CONF_CLIENT_SECRET,
@@ -25,10 +24,10 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: WebOsTvConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id]
+ client: WebOsClient = entry.runtime_data
client_data = {
"is_registered": client.is_registered(),
diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py
index edcfdcfed8b..3c509a56d1e 100644
--- a/homeassistant/components/webostv/helpers.py
+++ b/homeassistant/components/webostv/helpers.py
@@ -1,15 +1,23 @@
-"""Helper functions for webOS Smart TV."""
+"""Helper functions for LG webOS TV."""
from __future__ import annotations
+import logging
+
from aiowebostv import WebOsClient
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
-from . import async_control_connect
-from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS
+from .const import DOMAIN, LIVE_TV_APP_ID
+
+_LOGGER = logging.getLogger(__name__)
+
+type WebOsTvConfigEntry = ConfigEntry[WebOsClient]
@callback
@@ -31,7 +39,7 @@ def async_get_device_entry_by_device_id(
def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str:
"""Get device ID from an entity ID.
- Raises ValueError if entity or device ID is invalid.
+ Raises HomeAssistantError if entity or device ID is invalid.
"""
ent_reg = er.async_get(hass)
entity_entry = ent_reg.async_get(entity_id)
@@ -41,7 +49,11 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s
or entity_entry.device_id is None
or entity_entry.platform != DOMAIN
):
- raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_entity_id",
+ translation_placeholders={"entity_id": entity_id},
+ )
return entity_entry.device_id
@@ -55,24 +67,24 @@ def async_get_client_by_device_entry(
Raises ValueError if client is not found.
"""
for config_entry_id in device.config_entries:
- if client := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry_id):
- break
-
- if not client:
- raise ValueError(
- f"Device {device.id} is not from an existing {DOMAIN} config entry"
+ entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry(
+ config_entry_id
)
+ if entry and entry.domain == DOMAIN:
+ if entry.state is ConfigEntryState.LOADED:
+ return entry.runtime_data
- return client
+ raise ValueError(
+ f"Device {device.id} is not from a loaded {DOMAIN} config entry"
+ )
+
+ raise ValueError(
+ f"Device {device.id} is not from an existing {DOMAIN} config entry"
+ )
-async def async_get_sources(host: str, key: str) -> list[str]:
+def get_sources(client: WebOsClient) -> list[str]:
"""Construct sources list."""
- try:
- client = await async_control_connect(host, key)
- except WEBOSTV_EXCEPTIONS:
- return []
-
sources = []
found_live_tv = False
for app in client.apps.values():
@@ -90,3 +102,15 @@ async def async_get_sources(host: str, key: str) -> list[str]:
# Preserve order when filtering duplicates
return list(dict.fromkeys(sources))
+
+
+def update_client_key(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None:
+ """Check and update stored client key if key has changed."""
+ client: WebOsClient = entry.runtime_data
+ host = entry.data[CONF_HOST]
+ key = entry.data[CONF_CLIENT_SECRET]
+
+ if client.client_key != key:
+ _LOGGER.debug("Updating client key for host %s", host)
+ data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key}
+ hass.config_entries.async_update_entry(entry, data=data)
diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json
index 6c826c2f997..174e8025dd0 100644
--- a/homeassistant/components/webostv/manifest.json
+++ b/homeassistant/components/webostv/manifest.json
@@ -1,12 +1,12 @@
{
"domain": "webostv",
- "name": "LG webOS Smart TV",
+ "name": "LG webOS TV",
"codeowners": ["@thecode"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
- "requirements": ["aiowebostv==0.4.2"],
+ "requirements": ["aiowebostv==0.6.1"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"
diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py
index 239780e3f01..c8b871b3bf2 100644
--- a/homeassistant/components/webostv/media_player.py
+++ b/homeassistant/components/webostv/media_player.py
@@ -1,9 +1,9 @@
-"""Support for interface with an LG webOS Smart TV."""
+"""Support for interface with an LG webOS TV."""
from __future__ import annotations
import asyncio
-from collections.abc import Awaitable, Callable, Coroutine
+from collections.abc import Callable, Coroutine
from contextlib import suppress
from datetime import timedelta
from functools import wraps
@@ -12,6 +12,7 @@ import logging
from typing import Any, Concatenate, cast
from aiowebostv import WebOsClient, WebOsTvPairError
+import voluptuous as vol
from homeassistant import util
from homeassistant.components.media_player import (
@@ -21,32 +22,30 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- ATTR_SUPPORTED_FEATURES,
- ENTITY_MATCH_ALL,
- ENTITY_MATCH_NONE,
-)
-from homeassistant.core import HomeAssistant
+from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES
+from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
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.restore_state import RestoreEntity
from homeassistant.helpers.trigger import PluggableAction
+from homeassistant.helpers.typing import VolDictType
-from . import update_client_key
from .const import (
+ ATTR_BUTTON,
ATTR_PAYLOAD,
ATTR_SOUND_OUTPUT,
CONF_SOURCES,
- DATA_CONFIG_ENTRY,
DOMAIN,
LIVE_TV_APP_ID,
+ SERVICE_BUTTON,
+ SERVICE_COMMAND,
+ SERVICE_SELECT_SOUND_OUTPUT,
WEBOSTV_EXCEPTIONS,
)
+from .helpers import WebOsTvConfigEntry, update_client_key
from .triggers.turn_on import async_get_turn_on_trigger
_LOGGER = logging.getLogger(__name__)
@@ -68,55 +67,100 @@ SUPPORT_WEBOSTV_VOLUME = (
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
+PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=10)
+BUTTON_SCHEMA: VolDictType = {vol.Required(ATTR_BUTTON): cv.string}
+COMMAND_SCHEMA: VolDictType = {
+ vol.Required(ATTR_COMMAND): cv.string,
+ vol.Optional(ATTR_PAYLOAD): dict,
+}
+SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string}
+
+SERVICES = (
+ (
+ SERVICE_BUTTON,
+ BUTTON_SCHEMA,
+ "async_button",
+ SupportsResponse.NONE,
+ ),
+ (
+ SERVICE_COMMAND,
+ COMMAND_SCHEMA,
+ "async_command",
+ SupportsResponse.OPTIONAL,
+ ),
+ (
+ SERVICE_SELECT_SOUND_OUTPUT,
+ SOUND_OUTPUT_SCHEMA,
+ "async_select_sound_output",
+ SupportsResponse.OPTIONAL,
+ ),
+)
+
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: WebOsTvConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up the LG webOS Smart TV platform."""
- client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id]
- async_add_entities([LgWebOSMediaPlayerEntity(entry, client)])
+ """Set up the LG webOS TV platform."""
+ platform = entity_platform.async_get_current_platform()
+
+ for service_name, schema, method, supports_response in SERVICES:
+ platform.async_register_entity_service(
+ service_name, schema, method, supports_response=supports_response
+ )
+
+ async_add_entities([LgWebOSMediaPlayerEntity(entry)])
-def cmd[_T: LgWebOSMediaPlayerEntity, **_P](
- func: Callable[Concatenate[_T, _P], Awaitable[None]],
-) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
+def cmd[_R, **_P](
+ func: Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]]:
"""Catch command exceptions."""
@wraps(func)
- async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
+ async def cmd_wrapper(
+ self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs
+ ) -> _R:
"""Wrap all command methods."""
- try:
- await func(self, *args, **kwargs)
- except WEBOSTV_EXCEPTIONS as exc:
- if self.state != MediaPlayerState.OFF:
- raise HomeAssistantError(
- f"Error calling {func.__name__} on entity {self.entity_id},"
- f" state:{self.state}"
- ) from exc
- _LOGGER.warning(
- "Error calling %s on entity %s, state:%s, error: %r",
- func.__name__,
- self.entity_id,
- self.state,
- exc,
+ if self.state is MediaPlayerState.OFF:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="device_off",
+ translation_placeholders={
+ "name": str(self._entry.title),
+ "func": func.__name__,
+ },
)
+ try:
+ return await func(self, *args, **kwargs)
+ except WEBOSTV_EXCEPTIONS as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={
+ "name": str(self._entry.title),
+ "func": func.__name__,
+ "error": str(error),
+ },
+ ) from error
return cmd_wrapper
class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
- """Representation of a LG webOS Smart TV."""
+ """Representation of a LG webOS TV."""
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_has_entity_name = True
_attr_name = None
- def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None:
+ def __init__(self, entry: WebOsTvConfigEntry) -> None:
"""Initialize the webos device."""
self._entry = entry
- self._client = client
+ self._client = entry.runtime_data
self._attr_assumed_state = True
self._device_name = entry.title
self._attr_unique_id = entry.unique_id
@@ -142,10 +186,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
)
)
- self.async_on_remove(
- async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler)
- )
-
await self._client.register_state_update_callback(
self.async_handle_state_update
)
@@ -165,19 +205,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
"""Call disconnect on removal."""
self._client.unregister_state_update_callback(self.async_handle_state_update)
- async def async_signal_handler(self, data: dict[str, Any]) -> None:
- """Handle domain-specific signal by calling appropriate method."""
- if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE:
- return
-
- if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids:
- params = {
- key: value
- for key, value in data.items()
- if key not in ["entity_id", "method"]
- }
- await getattr(self, data["method"])(**params)
-
async def async_handle_state_update(self, _client: WebOsClient) -> None:
"""Update state from WebOsClient."""
self._update_states()
@@ -194,7 +221,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
self._attr_volume_level = None
if self._client.volume is not None:
- self._attr_volume_level = cast(float, self._client.volume / 100.0)
+ self._attr_volume_level = self._client.volume / 100.0
self._attr_source = self._current_source
self._attr_source_list = sorted(self._source_list)
@@ -220,7 +247,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
if self.state != MediaPlayerState.OFF or not self._supported_features:
supported = SUPPORT_WEBOSTV
- if self._client.sound_output in ("external_arc", "external_speaker"):
+ if self._client.sound_output == "external_speaker":
supported = supported | SUPPORT_WEBOSTV_VOLUME
elif self._client.sound_output != "lineout":
supported = (
@@ -238,13 +265,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
)
self._attr_assumed_state = True
- if (
- self._client.is_on
- and self._client.media_state is not None
- and self._client.media_state.get("foregroundAppInfo") is not None
- ):
+ if self._client.is_on and self._client.media_state:
self._attr_assumed_state = False
- for entry in self._client.media_state.get("foregroundAppInfo"):
+ for entry in self._client.media_state:
if entry.get("playState") == "playing":
self._attr_state = MediaPlayerState.PLAYING
elif entry.get("playState") == "paused":
@@ -252,7 +275,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
elif entry.get("playState") == "unloaded":
self._attr_state = MediaPlayerState.IDLE
- if self._client.system_info is not None or self.state != MediaPlayerState.OFF:
+ if self.state != MediaPlayerState.OFF:
maj_v = self._client.software_info.get("major_ver")
min_v = self._client.software_info.get("minor_ver")
if maj_v and min_v:
@@ -325,13 +348,13 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
if self._client.is_connected():
return
- with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError):
+ with suppress(*WEBOSTV_EXCEPTIONS):
try:
await self._client.connect()
except WebOsTvPairError:
self._entry.async_start_reauth(self.hass)
else:
- update_client_key(self.hass, self._entry, self._client)
+ update_client_key(self.hass, self._entry)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
@@ -372,9 +395,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
await self._client.set_mute(mute)
@cmd
- async def async_select_sound_output(self, sound_output: str) -> None:
+ async def async_select_sound_output(self, sound_output: str) -> ServiceResponse:
"""Select the sound output."""
- await self._client.change_sound_output(sound_output)
+ return await self._client.change_sound_output(sound_output)
@cmd
async def async_media_play_pause(self) -> None:
@@ -388,10 +411,14 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
async def async_select_source(self, source: str) -> None:
"""Select input source."""
if (source_dict := self._source_list.get(source)) is None:
- _LOGGER.warning(
- "Source %s not found for %s", source, self._friendly_name_internal()
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="source_not_found",
+ translation_placeholders={
+ "source": source,
+ "name": str(self._friendly_name_internal()),
+ },
)
- return
if source_dict.get("title"):
await self._client.launch_app(source_dict["id"])
elif source_dict.get("label"):
@@ -404,7 +431,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
"""Play a piece of media."""
_LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id)
- if media_type == MediaType.CHANNEL:
+ if media_type == MediaType.CHANNEL and self._client.channels:
_LOGGER.debug("Searching channel")
partial_match_channel_id = None
perfect_match_channel_id = None
@@ -473,9 +500,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
await self._client.button(button)
@cmd
- async def async_command(self, command: str, **kwargs: Any) -> None:
+ async def async_command(self, command: str, **kwargs: Any) -> ServiceResponse:
"""Send a command."""
- await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD))
+ return await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD))
async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]:
"""Retrieve an image.
diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py
index 43320687ce8..2393cb4cd07 100644
--- a/homeassistant/components/webostv/notify.py
+++ b/homeassistant/components/webostv/notify.py
@@ -1,20 +1,21 @@
-"""Support for LG WebOS TV notification service."""
+"""Support for LG webOS TV notification service."""
from __future__ import annotations
-import logging
from typing import Any
-from aiowebostv import WebOsClient, WebOsTvPairError
+from aiowebostv import WebOsClient
from homeassistant.components.notify import ATTR_DATA, BaseNotificationService
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCEPTIONS
+from . import WebOsTvConfigEntry
+from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS
-_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
async def async_get_service(
@@ -27,30 +28,53 @@ async def async_get_service(
if discovery_info is None:
return None
- client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][discovery_info[ATTR_CONFIG_ENTRY_ID]]
+ config_entry = hass.config_entries.async_get_entry(
+ discovery_info[ATTR_CONFIG_ENTRY_ID]
+ )
+ assert config_entry is not None
- return LgWebOSNotificationService(client)
+ return LgWebOSNotificationService(config_entry)
class LgWebOSNotificationService(BaseNotificationService):
- """Implement the notification service for LG WebOS TV."""
+ """Implement the notification service for LG webOS TV."""
- def __init__(self, client: WebOsClient) -> None:
+ def __init__(self, entry: WebOsTvConfigEntry) -> None:
"""Initialize the service."""
- self._client = client
+ self._entry = entry
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the tv."""
- try:
- if not self._client.is_connected():
- await self._client.connect()
+ client: WebOsClient = self._entry.runtime_data
+ data = kwargs[ATTR_DATA]
+ icon_path = data.get(ATTR_ICON) if data else None
- data = kwargs[ATTR_DATA]
- icon_path = data.get(ATTR_ICON) if data else None
- await self._client.send_message(message, icon_path=icon_path)
- except WebOsTvPairError:
- _LOGGER.error("Pairing with TV failed")
- except FileNotFoundError:
- _LOGGER.error("Icon %s not found", icon_path)
- except WEBOSTV_EXCEPTIONS:
- _LOGGER.error("TV unreachable")
+ if not client.is_on:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="notify_device_off",
+ translation_placeholders={
+ "name": str(self._entry.title),
+ "func": __name__,
+ },
+ )
+ try:
+ await client.send_message(message, icon_path=icon_path)
+ except FileNotFoundError as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="notify_icon_not_found",
+ translation_placeholders={
+ "name": str(self._entry.title),
+ "icon_path": str(icon_path),
+ },
+ ) from error
+ except WEBOSTV_EXCEPTIONS as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="notify_communication_error",
+ translation_placeholders={
+ "name": str(self._entry.title),
+ "error": str(error),
+ },
+ ) from error
diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml
new file mode 100644
index 00000000000..70f845404cd
--- /dev/null
+++ b/homeassistant/components/webostv/quality_scale.yaml
@@ -0,0 +1,76 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: exempt
+ comment: The integration does not use common patterns.
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: The integration connects to a single device.
+ entity-category:
+ status: exempt
+ comment: The integration only registers one entity.
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: The integration only registers one entity.
+ entity-translations:
+ status: exempt
+ comment: There are no entities to translate.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: The only entity can use the device class.
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: The integration does not have anything to repair.
+ stale-devices:
+ status: exempt
+ comment: The integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json
index 3ceab5f50a3..f6d033af632 100644
--- a/homeassistant/components/webostv/strings.json
+++ b/homeassistant/components/webostv/strings.json
@@ -1,49 +1,61 @@
{
"config": {
- "flow_title": "LG webOS Smart TV",
+ "flow_title": "{name}",
"step": {
"user": {
- "description": "Turn on TV, fill the following fields and select **Submit**",
+ "description": "Turn on the TV, fill the host field and select **Submit**",
"data": {
- "host": "[%key:common::config_flow::data::host%]",
- "name": "[%key:common::config_flow::data::name%]"
+ "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
- "host": "Hostname or IP address of your webOS TV."
+ "host": "Hostname or IP address of your LG webOS TV."
}
},
"pairing": {
- "title": "webOS TV Pairing",
+ "title": "LG webOS TV Pairing",
"description": "Select **Submit** and accept the pairing request on your TV.\n\n"
},
"reauth_confirm": {
"title": "[%key:component::webostv::config::step::pairing::title%]",
"description": "[%key:component::webostv::config::step::pairing::description%]"
+ },
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "[%key:component::webostv::config::step::user::data_description::host%]"
+ }
}
},
"error": {
- "cannot_connect": "Failed to connect, please turn on your TV or check the IP address"
+ "cannot_connect": "Failed to connect, please turn on your TV and try again.",
+ "error_pairing": "Pairing failed, make sure to accept the pairing request on the TV and try again."
},
"abort": {
- "error_pairing": "Connected to LG webOS TV but not paired",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "reauth_unsuccessful": "Re-authentication was unsuccessful, please turn on your TV and try again."
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "wrong_device": "The configured device is not the same found on this Hostname or IP address."
}
},
"options": {
"step": {
"init": {
- "title": "Options for webOS Smart TV",
+ "title": "Options for LG webOS TV",
"description": "Select enabled sources",
"data": {
"sources": "Sources list"
+ },
+ "data_description": {
+ "sources": "List of sources to enable"
}
}
},
"error": {
- "cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on"
+ "cannot_connect": "[%key:component::webostv::config::error::cannot_connect%]",
+ "error_pairing": "[%key:component::webostv::config::error::error_pairing%]"
}
},
"device_automation": {
@@ -98,5 +110,34 @@
}
}
}
+ },
+ "exceptions": {
+ "device_off": {
+ "message": "Error calling {func} for device {name}: Device is off and cannot be controlled."
+ },
+ "communication_error": {
+ "message": "Communication error while calling {func} for device {name}: {error}"
+ },
+ "notify_device_off": {
+ "message": "Error sending notification to device {name}: Device is off and cannot be controlled."
+ },
+ "notify_icon_not_found": {
+ "message": "Icon {icon_path} not found when sending notification for device {name}"
+ },
+ "notify_communication_error": {
+ "message": "Communication error while sending notification to device {name}: {error}"
+ },
+ "unhandled_trigger_type": {
+ "message": "Unhandled trigger type: {trigger_type}"
+ },
+ "unknown_trigger_platform": {
+ "message": "Unknown trigger platform: {platform}"
+ },
+ "invalid_entity_id": {
+ "message": "Entity {entity_id} is not a valid webostv entity."
+ },
+ "source_not_found": {
+ "message": "Source {source} not found in the sources list for {name}."
+ }
}
}
diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py
index 3290aa4a448..f121daafb91 100644
--- a/homeassistant/components/webostv/trigger.py
+++ b/homeassistant/components/webostv/trigger.py
@@ -1,4 +1,4 @@
-"""webOS Smart TV trigger dispatcher."""
+"""LG webOS TV trigger dispatcher."""
from __future__ import annotations
@@ -6,6 +6,7 @@ from typing import cast
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerInfo,
@@ -13,6 +14,7 @@ from homeassistant.helpers.trigger import (
)
from homeassistant.helpers.typing import ConfigType
+from .const import DOMAIN
from .triggers import turn_on
TRIGGERS = {
@@ -24,8 +26,10 @@ def _get_trigger_platform(config: ConfigType) -> TriggerProtocol:
"""Return trigger platform."""
platform_split = config[CONF_PLATFORM].split(".", maxsplit=1)
if len(platform_split) < 2 or platform_split[1] not in TRIGGERS:
- raise ValueError(
- f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}"
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unknown_trigger_platform",
+ translation_placeholders={"platform": config[CONF_PLATFORM]},
)
return cast(TriggerProtocol, TRIGGERS[platform_split[1]])
diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py
index d8c5a28ef3f..89bdf5f90ee 100644
--- a/homeassistant/components/webostv/triggers/__init__.py
+++ b/homeassistant/components/webostv/triggers/__init__.py
@@ -1 +1 @@
-"""webOS Smart TV triggers."""
+"""LG webOS TV triggers."""
diff --git a/homeassistant/components/webostv/triggers/turn_on.py b/homeassistant/components/webostv/triggers/turn_on.py
index f2ecb8aa98d..648da690715 100644
--- a/homeassistant/components/webostv/triggers/turn_on.py
+++ b/homeassistant/components/webostv/triggers/turn_on.py
@@ -1,4 +1,4 @@
-"""webOS Smart TV device turn on trigger."""
+"""LG webOS TV device turn on trigger."""
from __future__ import annotations
diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py
index 62f1adc39b9..12473c86255 100644
--- a/homeassistant/components/websocket_api/connection.py
+++ b/homeassistant/components/websocket_api/connection.py
@@ -40,17 +40,17 @@ class ActiveConnection:
"""Handle an active websocket client connection."""
__slots__ = (
- "logger",
- "hass",
- "send_message",
- "user",
- "refresh_token_id",
- "subscriptions",
- "last_id",
- "can_coalesce",
- "supported_features",
- "handlers",
"binary_handlers",
+ "can_coalesce",
+ "handlers",
+ "hass",
+ "last_id",
+ "logger",
+ "refresh_token_id",
+ "send_message",
+ "subscriptions",
+ "supported_features",
+ "user",
)
def __init__(
@@ -189,13 +189,13 @@ class ActiveConnection:
if (
# Not using isinstance as we don't care about children
# as these are always coming from JSON
- type(msg) is not dict # noqa: E721
+ type(msg) is not dict
or (
not (cur_id := msg.get("id"))
- or type(cur_id) is not int # noqa: E721
+ or type(cur_id) is not int
or cur_id < 0
or not (type_ := msg.get("type"))
- or type(type_) is not str # noqa: E721
+ or type(type_) is not str
)
):
self.logger.error("Received invalid command: %s", msg)
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
index e7d57aebab6..ebca497193b 100644
--- a/homeassistant/components/websocket_api/http.py
+++ b/homeassistant/components/websocket_api/http.py
@@ -63,27 +63,27 @@ class WebSocketAdapter(logging.LoggerAdapter):
def process(self, msg: str, kwargs: Any) -> tuple[str, Any]:
"""Add connid to websocket log messages."""
assert self.extra is not None
- return f'[{self.extra["connid"]}] {msg}', kwargs
+ return f"[{self.extra['connid']}] {msg}", kwargs
class WebSocketHandler:
"""Handle an active websocket client connection."""
__slots__ = (
- "_hass",
- "_loop",
- "_request",
- "_wsock",
- "_handle_task",
- "_writer_task",
- "_closing",
"_authenticated",
- "_logger",
- "_peak_checker_unsub",
+ "_closing",
"_connection",
+ "_handle_task",
+ "_hass",
+ "_logger",
+ "_loop",
"_message_queue",
+ "_peak_checker_unsub",
"_ready_future",
"_release_ready_queue_size",
+ "_request",
+ "_writer_task",
+ "_wsock",
)
def __init__(self, hass: HomeAssistant, request: web.Request) -> None:
@@ -197,7 +197,7 @@ class WebSocketHandler:
# max pending messages.
return
- if type(message) is not bytes: # noqa: E721
+ if type(message) is not bytes:
if isinstance(message, dict):
message = message_to_json_bytes(message)
elif isinstance(message, str):
@@ -387,7 +387,14 @@ class WebSocketHandler:
raise Disconnect("Received close message during auth phase")
if msg.type is not WSMsgType.TEXT:
- raise Disconnect("Received non-Text message during auth phase")
+ if msg.type is WSMsgType.ERROR:
+ # msg.data is the exception
+ raise Disconnect(
+ f"Received error message during auth phase: {msg.data}"
+ )
+ raise Disconnect(
+ f"Received non-Text message of type {msg.type} during auth phase"
+ )
try:
auth_msg_data = json_loads(msg.data)
@@ -477,7 +484,12 @@ class WebSocketHandler:
continue
if msg_type is not WSMsgType.TEXT:
- raise Disconnect("Received non-Text message.")
+ if msg_type is WSMsgType.ERROR:
+ # msg.data is the exception
+ raise Disconnect(
+ f"Received error message during command phase: {msg.data}"
+ )
+ raise Disconnect(f"Received non-Text message of type {msg_type}.")
try:
command_msg_data = json_loads(msg_data)
@@ -490,7 +502,7 @@ class WebSocketHandler:
)
# command_msg_data is always deserialized from JSON as a list
- if type(command_msg_data) is not list: # noqa: E721
+ if type(command_msg_data) is not list:
async_handle_str(command_msg_data)
continue
diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py
index a043a3a6845..d8d8616c867 100644
--- a/homeassistant/components/weheat/__init__.py
+++ b/homeassistant/components/weheat/__init__.py
@@ -2,13 +2,17 @@
from __future__ import annotations
+from http import HTTPStatus
+
+import aiohttp
from weheat.abstractions.discovery import HeatPumpDiscovery
from weheat.exceptions import UnauthorizedException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
@@ -28,12 +32,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo
session = OAuth2Session(hass, entry, implementation)
+ try:
+ await session.async_ensure_token_valid()
+ except aiohttp.ClientResponseError as ex:
+ LOGGER.warning("API error: %s (%s)", ex.status, ex.message)
+ if ex.status in (
+ HTTPStatus.BAD_REQUEST,
+ HTTPStatus.UNAUTHORIZED,
+ HTTPStatus.FORBIDDEN,
+ ):
+ raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
+ raise ConfigEntryNotReady from ex
+
token = session.token[CONF_ACCESS_TOKEN]
entry.runtime_data = []
# fetch a list of the heat pumps the entry can access
try:
- discovered_heat_pumps = await HeatPumpDiscovery.discover_active(API_URL, token)
+ discovered_heat_pumps = await HeatPumpDiscovery.async_discover_active(
+ API_URL, token, async_get_clientsession(hass)
+ )
except UnauthorizedException as error:
raise ConfigEntryAuthFailed from error
diff --git a/homeassistant/components/weheat/api.py b/homeassistant/components/weheat/api.py
deleted file mode 100644
index b1f5c0b3eff..00000000000
--- a/homeassistant/components/weheat/api.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""API for Weheat bound to Home Assistant OAuth."""
-
-from aiohttp import ClientSession
-from weheat.abstractions import AbstractAuth
-
-from homeassistant.const import CONF_ACCESS_TOKEN
-from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
-
-from .const import API_URL
-
-
-class AsyncConfigEntryAuth(AbstractAuth):
- """Provide Weheat authentication tied to an OAuth2 based config entry."""
-
- def __init__(
- self,
- websession: ClientSession,
- oauth_session: OAuth2Session,
- ) -> None:
- """Initialize Weheat auth."""
- super().__init__(websession, host=API_URL)
- self._oauth_session = oauth_session
-
- async def async_get_access_token(self) -> str:
- """Return a valid access token."""
- await self._oauth_session.async_ensure_token_valid()
-
- return self._oauth_session.token[CONF_ACCESS_TOKEN]
diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py
index ea939227e77..1fb8f614a40 100644
--- a/homeassistant/components/weheat/binary_sensor.py
+++ b/homeassistant/components/weheat/binary_sensor.py
@@ -18,6 +18,9 @@ from . import WeheatConfigEntry
from .coordinator import WeheatDataUpdateCoordinator
from .entity import WeheatEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription):
diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py
index b1a0b5dd4ea..2911ebdd49b 100644
--- a/homeassistant/components/weheat/config_flow.py
+++ b/homeassistant/components/weheat/config_flow.py
@@ -4,10 +4,11 @@ from collections.abc import Mapping
import logging
from typing import Any
-from weheat.abstractions.user import get_user_id_from_token
+from weheat.abstractions.user import async_get_user_id_from_token
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES
@@ -33,8 +34,10 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Override the create entry method to change to the step to find the heat pumps."""
# get the user id and use that as unique id for this entry
- user_id = await get_user_id_from_token(
- API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN]
+ user_id = await async_get_user_id_from_token(
+ API_URL,
+ data[CONF_TOKEN][CONF_ACCESS_TOKEN],
+ async_get_clientsession(self.hass),
)
await self.async_set_unique_id(user_id)
if self.source != SOURCE_REAUTH:
diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py
index a50e9daec18..4a85380e4a3 100644
--- a/homeassistant/components/weheat/coordinator.py
+++ b/homeassistant/components/weheat/coordinator.py
@@ -16,6 +16,7 @@ from weheat.exceptions import (
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -47,7 +48,9 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
self.heat_pump_info = heat_pump
- self._heat_pump_data = HeatPump(API_URL, heat_pump.uuid)
+ self._heat_pump_data = HeatPump(
+ API_URL, heat_pump.uuid, async_get_clientsession(hass)
+ )
self.session = session
@@ -68,19 +71,17 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
"""Return the model of the heat pump."""
return self.heat_pump_info.model
- def fetch_data(self) -> HeatPump:
- """Get the data from the API."""
+ async def _async_update_data(self) -> HeatPump:
+ """Fetch data from the API."""
+ await self.session.async_ensure_token_valid()
+
try:
- self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN])
+ await self._heat_pump_data.async_get_status(
+ self.session.token[CONF_ACCESS_TOKEN]
+ )
except UnauthorizedException as error:
raise ConfigEntryAuthFailed from error
except EXCEPTIONS as error:
raise UpdateFailed(error) from error
return self._heat_pump_data
-
- async def _async_update_data(self) -> HeatPump:
- """Fetch data from the API."""
- await self.session.async_ensure_token_valid()
-
- return await self.hass.async_add_executor_job(self.fetch_data)
diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json
index 1c6242de29c..1d60f66afba 100644
--- a/homeassistant/components/weheat/manifest.json
+++ b/homeassistant/components/weheat/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/weheat",
"iot_class": "cloud_polling",
- "requirements": ["weheat==2024.12.22"]
+ "requirements": ["weheat==2025.1.15"]
}
diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml
new file mode 100644
index 00000000000..705efce4421
--- /dev/null
+++ b/homeassistant/components/weheat/quality_scale.yaml
@@ -0,0 +1,93 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No service actions currently available
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: todo
+ comment: |
+ There are two servers that are used for this integration.
+ If the authentication server is unreachable, the user will not pass the configuration step.
+ If the backend is unreachable, an empty error message is displayed.
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: No service actions currently available
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ No configuration parameters available.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and thus does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a cloud service and thus does not support discovery.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices:
+ status: todo
+ comment: |
+ While unlikely to happen. Check if it is easily integrated.
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ There is no reconfiguration, as the only configuration step is authentication.
+ repair-issues:
+ status: exempt
+ comment: |
+ This is a cloud service and apart form reauthentication there are not user repairable issues.
+ stale-devices:
+ status: todo
+ comment: |
+ While unlikely to happen. Check if it is easily integrated.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py
index 3e5d9376c34..2d840aec86a 100644
--- a/homeassistant/components/weheat/sensor.py
+++ b/homeassistant/components/weheat/sensor.py
@@ -31,6 +31,9 @@ from .const import (
from .coordinator import WeheatDataUpdateCoordinator
from .entity import WeheatEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class WeHeatSensorEntityDescription(SensorEntityDescription):
diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py
index 6068cd3ff0b..619e0952457 100644
--- a/homeassistant/components/wemo/light.py
+++ b/homeassistant/components/wemo/light.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import async_wemo_dispatcher_connect
from .const import DOMAIN as WEMO_DOMAIN
diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py
index 64adcda4742..6231324bb0d 100644
--- a/homeassistant/components/whirlpool/__init__.py
+++ b/homeassistant/components/whirlpool/__init__.py
@@ -5,7 +5,7 @@ import logging
from aiohttp import ClientError
from whirlpool.appliancesmanager import AppliancesManager
-from whirlpool.auth import Auth
+from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth
from whirlpool.backendselector import BackendSelector
from homeassistant.config_entries import ConfigEntry
@@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
await auth.do_auth(store=False)
except (ClientError, TimeoutError) as ex:
raise ConfigEntryNotReady("Cannot connect") from ex
+ except WhirlpoolAccountLocked as ex:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="account_locked"
+ ) from ex
if not auth.is_access_token_valid():
_LOGGER.error("Authentication failed")
diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py
index 069a5ca1e4f..19715643e3a 100644
--- a/homeassistant/components/whirlpool/config_flow.py
+++ b/homeassistant/components/whirlpool/config_flow.py
@@ -9,13 +9,12 @@ from typing import Any
from aiohttp import ClientError
import voluptuous as vol
from whirlpool.appliancesmanager import AppliancesManager
-from whirlpool.auth import Auth
+from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth
from whirlpool.backendselector import BackendSelector
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN
@@ -40,31 +39,41 @@ REAUTH_SCHEMA = vol.Schema(
)
-async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]:
- """Validate the user input allows us to connect.
+async def authenticate(
+ hass: HomeAssistant, data: dict[str, str], check_appliances_exist: bool
+) -> str | None:
+ """Authenticate with the api.
- Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
+ data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
+ Returns the error translation key if authentication fails, or None on success.
"""
session = async_get_clientsession(hass)
region = CONF_REGIONS_MAP[data[CONF_REGION]]
brand = CONF_BRANDS_MAP[data[CONF_BRAND]]
backend_selector = BackendSelector(brand, region)
auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session)
+
try:
await auth.do_auth()
- except (TimeoutError, ClientError) as exc:
- raise CannotConnect from exc
+ except WhirlpoolAccountLocked:
+ return "account_locked"
+ except (TimeoutError, ClientError):
+ return "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ return "unknown"
if not auth.is_access_token_valid():
- raise InvalidAuth
+ return "invalid_auth"
- appliances_manager = AppliancesManager(backend_selector, auth, session)
- await appliances_manager.fetch_appliances()
+ if check_appliances_exist:
+ appliances_manager = AppliancesManager(backend_selector, auth, session)
+ await appliances_manager.fetch_appliances()
- if not appliances_manager.aircons and not appliances_manager.washer_dryers:
- raise NoAppliances
+ if not appliances_manager.aircons and not appliances_manager.washer_dryers:
+ return "no_appliances"
- return {"title": data[CONF_USERNAME]}
+ return None
class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -90,14 +99,10 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN):
brand = user_input[CONF_BRAND]
data = {**reauth_entry.data, CONF_PASSWORD: password, CONF_BRAND: brand}
- try:
- await validate_input(self.hass, data)
- except InvalidAuth:
- errors["base"] = "invalid_auth"
- except (CannotConnect, TimeoutError):
- errors["base"] = "cannot_connect"
- else:
+ error_key = await authenticate(self.hass, data, False)
+ if not error_key:
return self.async_update_reload_and_abort(reauth_entry, data=data)
+ errors["base"] = error_key
return self.async_show_form(
step_id="reauth_confirm",
@@ -113,38 +118,17 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
- errors = {}
-
- try:
- info = await validate_input(self.hass, user_input)
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except InvalidAuth:
- errors["base"] = "invalid_auth"
- except NoAppliances:
- errors["base"] = "no_appliances"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
+ error_key = await authenticate(self.hass, user_input, True)
+ if not error_key:
await self.async_set_unique_id(
user_input[CONF_USERNAME].lower(), raise_on_progress=False
)
self._abort_if_unique_id_configured()
- return self.async_create_entry(title=info["title"], data=user_input)
+ return self.async_create_entry(
+ title=user_input[CONF_USERNAME], data=user_input
+ )
+ errors = {"base": error_key}
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
-
-
-class CannotConnect(HomeAssistantError):
- """Error to indicate we cannot connect."""
-
-
-class InvalidAuth(HomeAssistantError):
- """Error to indicate there is invalid auth."""
-
-
-class NoAppliances(HomeAssistantError):
- """Error to indicate no supported appliances in the user account."""
diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json
index b463a1a76f8..67901eea482 100644
--- a/homeassistant/components/whirlpool/manifest.json
+++ b/homeassistant/components/whirlpool/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["whirlpool"],
- "requirements": ["whirlpool-sixth-sense==0.18.11"]
+ "requirements": ["whirlpool-sixth-sense==0.18.12"]
}
diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py
index b84518cedf1..9180164c272 100644
--- a/homeassistant/components/whirlpool/sensor.py
+++ b/homeassistant/components/whirlpool/sensor.py
@@ -291,9 +291,8 @@ class WasherDryerTimeClass(RestoreSensor):
seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining"))
)
- if (
- self._attr_native_value is None
- or isinstance(self._attr_native_value, datetime)
+ if self._attr_native_value is None or (
+ isinstance(self._attr_native_value, datetime)
and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60)
):
self._attr_native_value = new_timestamp
diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json
index 09257652ece..95df3fb9098 100644
--- a/homeassistant/components/whirlpool/strings.json
+++ b/homeassistant/components/whirlpool/strings.json
@@ -1,4 +1,7 @@
{
+ "common": {
+ "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it"
+ },
"config": {
"step": {
"user": {
@@ -31,6 +34,7 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
+ "account_locked": "[%key:component::whirlpool::common::account_locked_error%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
@@ -85,5 +89,10 @@
"name": "End time"
}
}
+ },
+ "exceptions": {
+ "account_locked": {
+ "message": "[%key:component::whirlpool::common::account_locked_error%]"
+ }
}
}
diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py
index 74663d61d8f..1036e5b1ead 100644
--- a/homeassistant/components/wilight/config_flow.py
+++ b/homeassistant/components/wilight/config_flow.py
@@ -5,9 +5,15 @@ from urllib.parse import urlparse
import pywilight
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from .const import DOMAIN
@@ -53,25 +59,25 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=self._title, data=data)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered WiLight."""
# Filter out basic information
if (
not discovery_info.ssdp_location
- or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info.upnp
- or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp
- or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp
- or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp
+ or ATTR_UPNP_MANUFACTURER not in discovery_info.upnp
+ or ATTR_UPNP_SERIAL not in discovery_info.upnp
+ or ATTR_UPNP_MODEL_NAME not in discovery_info.upnp
+ or ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp
):
return self.async_abort(reason="not_wilight_device")
# Filter out non-WiLight devices
- if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER:
+ if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER:
return self.async_abort(reason="not_wilight_device")
host = urlparse(discovery_info.ssdp_location).hostname
- serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
- model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
+ serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL]
+ model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
if not self._wilight_update(host, serial_number, model_name):
return self.async_abort(reason="not_wilight_device")
diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py
index a32e940073b..806e7abed00 100644
--- a/homeassistant/components/wirelesstag/__init__.py
+++ b/homeassistant/components/wirelesstag/__init__.py
@@ -10,7 +10,7 @@ from wirelesstagpy.exceptions import WirelessTagsException
from homeassistant.components import persistent_notification
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py
index 9e8075dd874..8a0957e16e3 100644
--- a/homeassistant/components/wirelesstag/binary_sensor.py
+++ b/homeassistant/components/wirelesstag/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py
index 7a3cbe5efe2..9b92480ecf9 100644
--- a/homeassistant/components/wirelesstag/sensor.py
+++ b/homeassistant/components/wirelesstag/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py
index cae5d63988c..9fa630d4f55 100644
--- a/homeassistant/components/wirelesstag/switch.py
+++ b/homeassistant/components/wirelesstag/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py
index 691026ccb9a..856aeeffc5c 100644
--- a/homeassistant/components/withings/binary_sensor.py
+++ b/homeassistant/components/withings/binary_sensor.py
@@ -10,8 +10,8 @@ from homeassistant.components.binary_sensor import (
)
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
-import homeassistant.helpers.entity_registry as er
from . import WithingsConfigEntry
from .const import DOMAIN
diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py
index acab0fa5c40..ac867fbfdca 100644
--- a/homeassistant/components/withings/calendar.py
+++ b/homeassistant/components/withings/calendar.py
@@ -10,8 +10,8 @@ from aiowithings import WithingsClient, WorkoutCategory
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
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
-import homeassistant.helpers.entity_registry as er
from . import DOMAIN, WithingsConfigEntry
from .coordinator import WithingsWorkoutDataUpdateCoordinator
diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py
index 71bc0a9aaa8..92b25389450 100644
--- a/homeassistant/components/wiz/config_flow.py
+++ b/homeassistant/components/wiz/config_flow.py
@@ -10,10 +10,11 @@ from pywizlight.discovery import DiscoveredBulb
from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError
import voluptuous as vol
-from homeassistant.components import dhcp, onboarding
+from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.util.network import is_ip_address
from .const import DEFAULT_NAME, DISCOVER_SCAN_TIMEOUT, DOMAIN, WIZ_CONNECT_EXCEPTIONS
@@ -38,7 +39,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_devices: dict[str, DiscoveredBulb] = {}
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
self._discovered_device = DiscoveredBulb(
diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py
index 812a0500d1a..2e0b7b1c793 100644
--- a/homeassistant/components/wled/config_flow.py
+++ b/homeassistant/components/wled/config_flow.py
@@ -7,7 +7,7 @@ from typing import Any
import voluptuous as vol
from wled import WLED, Device, WLEDConnectionError
-from homeassistant.components import onboarding, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -17,6 +17,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN
@@ -68,7 +69,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
# Abort quick if the mac address is provided by discovery info
diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py
index 2ce58ec9eca..94deed11c08 100644
--- a/homeassistant/components/wmspro/config_flow.py
+++ b/homeassistant/components/wmspro/config_flow.py
@@ -10,12 +10,11 @@ import aiohttp
import voluptuous as vol
from wmspro.webcontrol import WebControlPro
-from homeassistant.components import dhcp
-from homeassistant.components.dhcp import DhcpServiceInfo
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_DHCP, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN, SUGGESTED_HOST
@@ -34,7 +33,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle the DHCP discovery step."""
unique_id = format_mac(discovery_info.macaddress)
@@ -95,7 +94,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_configured")
return self.async_create_entry(title=host, data=user_input)
- if self.source == dhcp.DOMAIN:
+ if self.source == SOURCE_DHCP:
discovery_info: DhcpServiceInfo = self.init_data
data_values = {CONF_HOST: discovery_info.ip}
else:
diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py
index 3684208f102..3aad6d805d0 100644
--- a/homeassistant/components/workday/binary_sensor.py
+++ b/homeassistant/components/workday/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
diff --git a/homeassistant/components/worldclock/config_flow.py b/homeassistant/components/worldclock/config_flow.py
index eebf0d59dcb..e91d2e40f63 100644
--- a/homeassistant/components/worldclock/config_flow.py
+++ b/homeassistant/components/worldclock/config_flow.py
@@ -83,10 +83,6 @@ CONFIG_FLOW = {
schema=get_schema,
validate_user_input=validate_duplicate,
),
- "import": SchemaFlowFormStep(
- schema=get_schema,
- validate_user_input=validate_duplicate,
- ),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py
index f4879ca08c4..88e5a317cdd 100644
--- a/homeassistant/components/worldclock/sensor.py
+++ b/homeassistant/components/worldclock/sensor.py
@@ -4,62 +4,15 @@ from __future__ import annotations
from datetime import tzinfo
-import voluptuous as vol
-
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorEntity,
-)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.components.sensor import SensorEntity
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_TIME_ZONE
-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
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
-from .const import CONF_TIME_FORMAT, DEFAULT_NAME, DEFAULT_TIME_STR_FORMAT, DOMAIN
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_TIME_ZONE): cv.time_zone,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_TIME_FORMAT, default=DEFAULT_TIME_STR_FORMAT): cv.string,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the World clock sensor."""
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
- )
-
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.2.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Worldclock",
- },
- )
+from .const import CONF_TIME_FORMAT, DOMAIN
async def async_setup_entry(
diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py
index 45f39894abb..1a64954bb4a 100644
--- a/homeassistant/components/worldtidesinfo/sensor.py
+++ b/homeassistant/components/worldtidesinfo/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py
index 50700b78f35..ed3312fc950 100644
--- a/homeassistant/components/worxlandroid/sensor.py
+++ b/homeassistant/components/worxlandroid/sensor.py
@@ -14,8 +14,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py
index 73714b75c95..8ae93c809f2 100644
--- a/homeassistant/components/wsdot/sensor.py
+++ b/homeassistant/components/wsdot/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py
index 5fdcb1a5484..41e7b9cf1e6 100644
--- a/homeassistant/components/wyoming/config_flow.py
+++ b/homeassistant/components/wyoming/config_flow.py
@@ -8,10 +8,10 @@ from urllib.parse import urlparse
import voluptuous as vol
-from homeassistant.components import zeroconf
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .data import WyomingService
@@ -69,6 +69,19 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.uuid)
self._abort_if_unique_id_configured()
+ uri = urlparse(discovery_info.config["uri"])
+ for entry in self._async_current_entries(include_ignore=True):
+ if (
+ entry.data[CONF_HOST] == uri.hostname
+ and entry.data[CONF_PORT] == uri.port
+ ):
+ return self.async_update_reload_and_abort(
+ entry,
+ unique_id=discovery_info.uuid,
+ reload_even_if_entry_is_unchanged=False,
+ reason="already_configured",
+ )
+
self._hassio_discovery = discovery_info
self.context.update(
{
@@ -104,7 +117,7 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Zeroconf discovery info: %s", discovery_info)
@@ -126,6 +139,19 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = {"name": self._name}
+ for entry in self._async_current_entries(include_ignore=True):
+ if (
+ entry.data[CONF_HOST] == service.host
+ and entry.data[CONF_PORT] == service.port
+ and entry.source != SOURCE_HASSIO
+ ):
+ return self.async_update_reload_and_abort(
+ entry,
+ unique_id=unique_id,
+ reload_even_if_entry_is_unchanged=False,
+ reason="already_configured",
+ )
+
self._service = service
return await self.async_step_zeroconf_confirm()
diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py
index 9a17559c1f8..988d47925ac 100644
--- a/homeassistant/components/wyoming/conversation.py
+++ b/homeassistant/components/wyoming/conversation.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import ulid
+from homeassistant.util import ulid as ulid_util
from .const import DOMAIN
from .data import WyomingService
@@ -97,7 +97,7 @@ class WyomingConversationEntity(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
- conversation_id = user_input.conversation_id or ulid.ulid_now()
+ conversation_id = user_input.conversation_id or ulid_util.ulid_now()
intent_response = intent.IntentResponse(language=user_input.language)
try:
diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py
index 23343cb0f8d..fbdebe11657 100644
--- a/homeassistant/components/x10/light.py
+++ b/homeassistant/components/x10/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -81,9 +81,16 @@ class X10Light(LightEntity):
@property
def brightness(self):
- """Return the brightness of the light."""
+ """Return the brightness of the light, scaled to base class 0..255.
+
+ This needs to be scaled from 0..x for use with X10 dimmers.
+ """
return self._brightness
+ def normalize_x10_brightness(self, brightness: float) -> float:
+ """Return calculated brightness values."""
+ return int((brightness / 255) * 32)
+
@property
def is_on(self):
"""Return true if light is on."""
@@ -91,11 +98,37 @@ class X10Light(LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
- if self._is_cm11a:
- x10_command(f"on {self._id}")
- else:
- x10_command(f"fon {self._id}")
+ old_brightness = self._brightness
+ if old_brightness == 0:
+ # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate
+ old_brightness = 255
self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
+ brightness_diff = self.normalize_x10_brightness(
+ self._brightness
+ ) - self.normalize_x10_brightness(old_brightness)
+ command_suffix = ""
+ # heyu has quite a messy command structure - we'll just deal with it here
+ if brightness_diff == 0:
+ if self._is_cm11a:
+ command_prefix = "on"
+ else:
+ command_prefix = "fon"
+ elif brightness_diff > 0:
+ if self._is_cm11a:
+ command_prefix = "bright"
+ else:
+ command_prefix = "fbright"
+ command_suffix = f" {brightness_diff}"
+ else:
+ if self._is_cm11a:
+ if self._state:
+ command_prefix = "dim"
+ else:
+ command_prefix = "dimb"
+ else:
+ command_prefix = "fdim"
+ command_suffix = f" {-brightness_diff}"
+ x10_command(f"{command_prefix} {self._id}{command_suffix}")
self._state = True
def turn_off(self, **kwargs: Any) -> None:
@@ -104,6 +137,7 @@ class X10Light(LightEntity):
x10_command(f"off {self._id}")
else:
x10_command(f"foff {self._id}")
+ self._brightness = 0
self._state = False
def update(self) -> None:
diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py
index 5282a34903a..ab0d510a709 100644
--- a/homeassistant/components/xbox/__init__.py
+++ b/homeassistant/components/xbox/__init__.py
@@ -6,6 +6,7 @@ import logging
from xbox.webapi.api.client import XboxLiveClient
from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList
+from xbox.webapi.common.signed_session import SignedSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
@@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
- auth = api.AsyncConfigEntryAuth(session)
+ signed_session = await hass.async_add_executor_job(SignedSession)
+ auth = api.AsyncConfigEntryAuth(signed_session, session)
client = XboxLiveClient(auth)
consoles: SmartglassConsoleList = await client.smartglass.get_console_list()
diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py
index d4c47e4cc39..9fa7c14b5c9 100644
--- a/homeassistant/components/xbox/api.py
+++ b/homeassistant/components/xbox/api.py
@@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp
class AsyncConfigEntryAuth(AuthenticationManager):
"""Provide xbox authentication tied to an OAuth2 based config entry."""
- def __init__(self, oauth_session: OAuth2Session) -> None:
+ def __init__(
+ self, signed_session: SignedSession, oauth_session: OAuth2Session
+ ) -> None:
"""Initialize xbox auth."""
# Leaving out client credentials as they are handled by Home Assistant
- super().__init__(SignedSession(), "", "", "")
+ super().__init__(signed_session, "", "", "")
self._oauth_session = oauth_session
self.oauth = self._get_oauth_token()
diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py
index 9d4a29d2c78..5968a17f418 100644
--- a/homeassistant/components/xiaomi/device_tracker.py
+++ b/homeassistant/components/xiaomi/device_tracker.py
@@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py
index b7f4aa1942e..579994aaf6b 100644
--- a/homeassistant/components/xiaomi_aqara/__init__.py
+++ b/homeassistant/components/xiaomi_aqara/__init__.py
@@ -17,8 +17,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import device_registry as dr
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import (
diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py
index 6252e6849d0..e0484b80b7e 100644
--- a/homeassistant/components/xiaomi_aqara/config_flow.py
+++ b/homeassistant/components/xiaomi_aqara/config_flow.py
@@ -7,11 +7,11 @@ from typing import Any
import voluptuous as vol
from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery
-from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_INTERFACE,
@@ -153,7 +153,7 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
name = discovery_info.name
diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py
index c8057f1df4a..11ce7a0107b 100644
--- a/homeassistant/components/xiaomi_aqara/light.py
+++ b/homeassistant/components/xiaomi_aqara/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import DOMAIN, GATEWAYS_KEY
from .entity import XiaomiDevice
diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json
index a77b78c5a09..6221b9b9d65 100644
--- a/homeassistant/components/xiaomi_aqara/strings.json
+++ b/homeassistant/components/xiaomi_aqara/strings.json
@@ -7,14 +7,14 @@
"data": {
"interface": "The network interface to use",
"host": "IP address (optional)",
- "mac": "Mac Address (optional)"
+ "mac": "MAC address (optional)"
}
},
"settings": {
"title": "Optional settings",
"description": "The key (password) can be retrieved using this tutorial: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. If the key is not provided only sensors will be accessible",
"data": {
- "key": "The key of your gateway",
+ "key": "The key of your Gateway",
"name": "Name of the Gateway"
}
},
@@ -26,11 +26,11 @@
}
},
"error": {
- "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface",
+ "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running Home Assistant as interface",
"invalid_interface": "Invalid network interface",
- "invalid_key": "Invalid gateway key",
+ "invalid_key": "Invalid Gateway key",
"invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem",
- "invalid_mac": "Invalid Mac Address"
+ "invalid_mac": "Invalid MAC address"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -59,7 +59,7 @@
},
"ringtone_id": {
"name": "Ringtone ID",
- "description": "One of the allowed ringtone ids."
+ "description": "One of the allowed ringtone IDs."
},
"ringtone_vol": {
"name": "Ringtone volume",
diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py
index df2de381d39..c293d7832d0 100644
--- a/homeassistant/components/xiaomi_ble/config_flow.py
+++ b/homeassistant/components/xiaomi_ble/config_flow.py
@@ -306,7 +306,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry()
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py
index b068f4a1e61..c3ebc48d743 100644
--- a/homeassistant/components/xiaomi_miio/config_flow.py
+++ b/homeassistant/components/xiaomi_miio/config_flow.py
@@ -11,7 +11,6 @@ from micloud import MiCloud
from micloud.micloudexception import MiCloudAccessDenied
import voluptuous as vol
-from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -21,6 +20,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_CLOUD_COUNTRY,
@@ -145,7 +145,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_cloud()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
name = discovery_info.name
diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py
index 1dfc5e53410..518003ceedb 100644
--- a/homeassistant/components/xiaomi_miio/device_tracker.py
+++ b/homeassistant/components/xiaomi_miio/device_tracker.py
@@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
index e1de3f56252..12ed9f7195b 100644
--- a/homeassistant/components/xiaomi_miio/fan.py
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -33,7 +33,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
index 3f1f8b926b3..c1f778928d9 100644
--- a/homeassistant/components/xiaomi_miio/light.py
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -42,7 +42,7 @@ from homeassistant.const import (
CONF_TOKEN,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util, dt as dt_util
diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py
index eb0d6bca205..6729ce2e0f4 100644
--- a/homeassistant/components/xiaomi_miio/select.py
+++ b/homeassistant/components/xiaomi_miio/select.py
@@ -260,10 +260,10 @@ class XiaomiGenericSelector(XiaomiSelector):
if description.options_map:
self._options_map = {}
- for key, val in enum_class._member_map_.items(): # noqa: SLF001
+ for key, val in enum_class._member_map_.items():
self._options_map[description.options_map[key]] = val
else:
- self._options_map = enum_class._member_map_ # noqa: SLF001
+ self._options_map = enum_class._member_map_
self._reverse_map = {val: key for key, val in self._options_map.items()}
self._enum_class = enum_class
diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py
index 02f4d4e94e5..b4c4300dbe8 100644
--- a/homeassistant/components/xiaomi_miio/switch.py
+++ b/homeassistant/components/xiaomi_miio/switch.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py
index 675c802f79c..19cb4faf2b9 100644
--- a/homeassistant/components/xiaomi_tv/media_player.py
+++ b/homeassistant/components/xiaomi_tv/media_player.py
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py
index 3fb5dd166a1..968f925d1e8 100644
--- a/homeassistant/components/xmpp/notify.py
+++ b/homeassistant/components/xmpp/notify.py
@@ -35,8 +35,7 @@ from homeassistant.const import (
CONF_SENDER,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.template as template_helper
+from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py
index 6f7197817d7..15fb9d021c6 100644
--- a/homeassistant/components/xs1/__init__.py
+++ b/homeassistant/components/xs1/__init__.py
@@ -14,8 +14,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py
index b911c92ba0f..7fdad118cde 100644
--- a/homeassistant/components/yale/lock.py
+++ b/homeassistant/components/yale/lock.py
@@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import YaleConfigEntry, YaleData
from .entity import YaleEntity
diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py
index 17b6035321a..fa9584505e2 100644
--- a/homeassistant/components/yale_smart_alarm/binary_sensor.py
+++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py
@@ -86,7 +86,7 @@ class YaleDoorBatterySensor(YaleEntity, BinarySensorEntity):
) -> None:
"""Initiate Yale door battery Sensor."""
super().__init__(coordinator, data)
- self._attr_unique_id = f"{data["address"]}-battery"
+ self._attr_unique_id = f"{data['address']}-battery"
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py
index 3ceee367284..1aaad2aa63a 100644
--- a/homeassistant/components/yale_smart_alarm/config_flow.py
+++ b/homeassistant/components/yale_smart_alarm/config_flow.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from .const import (
CONF_AREA_ID,
diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py
index 7ece2a3448b..db63567fa92 100644
--- a/homeassistant/components/yale_smart_alarm/coordinator.py
+++ b/homeassistant/components/yale_smart_alarm/coordinator.py
@@ -84,7 +84,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
contact["address"]: contact["_state"] for contact in door_windows
}
_sensor_battery_map = {
- f"{contact["address"]}-battery": contact["_battery"]
+ f"{contact['address']}-battery": contact["_battery"]
for contact in door_windows
}
_temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors}
diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json
index bd3ba0f0186..ebcf0b3af63 100644
--- a/homeassistant/components/yale_smart_alarm/strings.json
+++ b/homeassistant/components/yale_smart_alarm/strings.json
@@ -92,7 +92,7 @@
"message": "Could not set lock, check system ready for lock"
},
"could_not_trigger_panic": {
- "message": "Could not trigger panic button for entity id {entity_id}: {error}"
+ "message": "Could not trigger panic button for entity ID {entity_id}: {error}"
}
}
}
diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py
index 6de74759686..0e1eabdf6b2 100644
--- a/homeassistant/components/yalexs_ble/config_flow.py
+++ b/homeassistant/components/yalexs_ble/config_flow.py
@@ -267,7 +267,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
current_unique_names = {
entry.data.get(CONF_LOCAL_NAME)
for entry in self._async_current_entries()
diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py
index d6ad54c4a3d..c43e547a71e 100644
--- a/homeassistant/components/yamaha_musiccast/config_flow.py
+++ b/homeassistant/components/yamaha_musiccast/config_flow.py
@@ -10,10 +10,14 @@ from aiohttp import ClientConnectorError
from aiomusiccast import MusicCastConnectionException, MusicCastDevice
import voluptuous as vol
-from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from . import get_upnp_desc
from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN
@@ -81,7 +85,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle ssdp discoveries."""
if not await MusicCastDevice.check_yamaha_ssdp(
@@ -89,7 +93,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
):
return self.async_abort(reason="yxc_control_url_missing")
- self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
+ self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL]
self.upnp_description = discovery_info.ssdp_location
# ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment
@@ -105,9 +109,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
self.context.update(
{
"title_placeholders": {
- "name": discovery_info.upnp.get(
- ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host
- )
+ "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host)
}
}
)
diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py
index 4384cc34836..cff14f2b67d 100644
--- a/homeassistant/components/yamaha_musiccast/media_player.py
+++ b/homeassistant/components/yamaha_musiccast/media_player.py
@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import uuid
+from homeassistant.util import uuid as uuid_util
from .const import (
ATTR_MAIN_SYNC,
@@ -735,7 +735,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
group = (
self.coordinator.data.group_id
if self.is_server
- else uuid.random_uuid_hex().upper()
+ else uuid_util.random_uuid_hex().upper()
)
ip_addresses = set()
diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py
index 95c4785a341..f87d29fffed 100644
--- a/homeassistant/components/yandex_transport/sensor.py
+++ b/homeassistant/components/yandex_transport/sensor.py
@@ -15,11 +15,11 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py
index 850afd05150..c7621eb639a 100644
--- a/homeassistant/components/yandextts/tts.py
+++ b/homeassistant/components/yandextts/tts.py
@@ -13,8 +13,8 @@ from homeassistant.components.tts import (
Provider,
)
from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py
index 9b71bbc3b16..0b3ceaf2aee 100644
--- a/homeassistant/components/yeelight/__init__.py
+++ b/homeassistant/components/yeelight/__init__.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, VolDictType
from .const import (
diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py
index 7a3a0a2f100..15975ba22bd 100644
--- a/homeassistant/components/yeelight/config_flow.py
+++ b/homeassistant/components/yeelight/config_flow.py
@@ -11,7 +11,7 @@ import yeelight
from yeelight.aio import AsyncBulb
from yeelight.main import get_known_models
-from homeassistant.components import dhcp, onboarding, ssdp, zeroconf
+from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
@@ -22,7 +22,10 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -69,21 +72,21 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_devices: dict[str, Any] = {}
async def async_step_homekit(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery from homekit."""
self._discovered_ip = discovery_info.host
return await self._async_handle_discovery()
async def async_step_dhcp(
- self, discovery_info: dhcp.DhcpServiceInfo
+ self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery from dhcp."""
self._discovered_ip = discovery_info.ip
return await self._async_handle_discovery()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery from zeroconf."""
self._discovered_ip = discovery_info.host
@@ -91,7 +94,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_handle_discovery_with_unique_id()
async def async_step_ssdp(
- self, discovery_info: ssdp.SsdpServiceInfo
+ self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery from ssdp."""
self._discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname
diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py
index 8cc3f2600e5..92ee3976f7f 100644
--- a/homeassistant/components/yeelight/light.py
+++ b/homeassistant/components/yeelight/light.py
@@ -32,13 +32,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_platform
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import VolDictType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from . import YEELIGHT_FLOW_TRANSITION_SCHEMA
from .const import (
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index eba970dc2db..cf7bc9c9035 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -16,7 +16,7 @@
},
"iot_class": "local_push",
"loggers": ["async_upnp_client", "yeelight"],
- "requirements": ["yeelight==0.7.14", "async-upnp-client==0.42.0"],
+ "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"],
"zeroconf": [
{
"type": "_miio._udp.local.",
diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py
index ac482504880..75156ab019b 100644
--- a/homeassistant/components/yeelight/scanner.py
+++ b/homeassistant/components/yeelight/scanner.py
@@ -9,17 +9,18 @@ from datetime import datetime
from functools import partial
from ipaddress import IPv4Address
import logging
-from typing import Self
+from typing import ClassVar, Self
from urllib.parse import urlparse
from async_upnp_client.search import SsdpSearchListener
from async_upnp_client.utils import CaseInsensitiveDict
from homeassistant import config_entries
-from homeassistant.components import network, ssdp
+from homeassistant.components import network
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_call_later, async_track_time_interval
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.util.async_ import create_eager_task
from .const import (
@@ -44,11 +45,11 @@ def _set_future_if_not_done(future: asyncio.Future[None]) -> None:
class YeelightScanner:
"""Scan for Yeelight devices."""
- _scanner: Self | None = None
+ _scanner: ClassVar[Self | None] = None
@classmethod
@callback
- def async_get(cls, hass: HomeAssistant) -> YeelightScanner:
+ def async_get(cls, hass: HomeAssistant) -> Self:
"""Get scanner instance."""
if cls._scanner is None:
cls._scanner = cls(hass)
@@ -171,7 +172,7 @@ class YeelightScanner:
self._hass,
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="",
ssdp_st=SSDP_ST,
ssdp_headers=response,
diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json
index 72baec52c85..72e400b7cf3 100644
--- a/homeassistant/components/yeelight/strings.json
+++ b/homeassistant/components/yeelight/strings.json
@@ -59,7 +59,7 @@
"services": {
"set_mode": {
"name": "Set mode",
- "description": "Sets a operation mode.",
+ "description": "Sets an operation mode.",
"fields": {
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
@@ -129,7 +129,7 @@
},
"set_auto_delay_off_scene": {
"name": "Set auto delay off scene",
- "description": "Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, Set a color scene, if light is off, it will be turned on.",
+ "description": "Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, it will be turned on.",
"fields": {
"minutes": {
"name": "Minutes",
@@ -143,7 +143,7 @@
},
"start_flow": {
"name": "Start flow",
- "description": "Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects.",
+ "description": "Starts a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects.",
"fields": {
"count": {
"name": "Count",
diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py
index 0d8247fc865..4cacd1def22 100644
--- a/homeassistant/components/yeelightsunflower/light.py
+++ b/homeassistant/components/yeelightsunflower/light.py
@@ -17,10 +17,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py
index a011d493dc9..8d622de70e7 100644
--- a/homeassistant/components/yolink/services.py
+++ b/homeassistant/components/yolink/services.py
@@ -19,6 +19,11 @@ from .const import (
SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub"
+_SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = (
+ (ATTR_VOLUME, lambda x: x),
+ (ATTR_TONE, lambda x: x.capitalize()),
+)
+
def async_register_services(hass: HomeAssistant) -> None:
"""Register services for YoLink integration."""
@@ -46,16 +51,16 @@ def async_register_services(hass: HomeAssistant) -> None:
identifier[1]
)
) is not None:
- tone_param = service_data[ATTR_TONE].capitalize()
- play_request = ClientRequest(
- "playAudio",
- {
- ATTR_TONE: tone_param,
- ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE],
- ATTR_VOLUME: service_data[ATTR_VOLUME],
- ATTR_REPEAT: service_data[ATTR_REPEAT],
- },
- )
+ params = {
+ ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE],
+ ATTR_REPEAT: service_data[ATTR_REPEAT],
+ }
+
+ for attr, transform in _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS:
+ if attr in service_data:
+ params[attr] = transform(service_data[attr])
+
+ play_request = ClientRequest("playAudio", params)
await device_coordinator.device.call_device(play_request)
hass.services.async_register(
@@ -64,9 +69,9 @@ def async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema(
{
vol.Required(ATTR_TARGET_DEVICE): cv.string,
- vol.Required(ATTR_TONE): cv.string,
+ vol.Optional(ATTR_TONE): cv.string,
vol.Required(ATTR_TEXT_MESSAGE): cv.string,
- vol.Required(ATTR_VOLUME): vol.All(
+ vol.Optional(ATTR_VOLUME): vol.All(
vol.Coerce(int), vol.Range(min=0, max=15)
),
vol.Optional(ATTR_REPEAT, default=0): vol.All(
diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml
index 5f7a3ec3122..7375962070e 100644
--- a/homeassistant/components/yolink/services.yaml
+++ b/homeassistant/components/yolink/services.yaml
@@ -14,7 +14,6 @@ play_on_speaker_hub:
selector:
text:
tone:
- required: true
default: "tip"
selector:
select:
@@ -25,7 +24,6 @@ play_on_speaker_hub:
- "tip"
translation_key: speaker_tone
volume:
- required: true
default: 8
selector:
number:
@@ -33,7 +31,6 @@ play_on_speaker_hub:
max: 15
step: 1
repeat:
- required: true
default: 0
selector:
number:
diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json
index 2f9a9454502..cbb092405d7 100644
--- a/homeassistant/components/yolink/strings.json
+++ b/homeassistant/components/yolink/strings.json
@@ -115,7 +115,7 @@
},
"volume": {
"name": "Volume",
- "description": "Speaker volume during playback."
+ "description": "Override the speaker volume during playback of this message only."
},
"repeat": {
"name": "Repeat",
diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py
index d475034cc9d..03a27b5a378 100644
--- a/homeassistant/components/youless/__init__.py
+++ b/homeassistant/components/youless/__init__.py
@@ -1,6 +1,5 @@
"""The youless integration."""
-from datetime import timedelta
import logging
from urllib.error import URLError
@@ -10,9 +9,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
+from .coordinator import YouLessCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -28,24 +27,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except URLError as exception:
raise ConfigEntryNotReady from exception
- async def async_update_data() -> YoulessAPI:
- """Fetch data from the API."""
- await hass.async_add_executor_job(api.update)
- return api
-
- coordinator = DataUpdateCoordinator(
- hass,
- _LOGGER,
- config_entry=entry,
- name="youless_gateway",
- update_method=async_update_data,
- update_interval=timedelta(seconds=10),
- )
-
- await coordinator.async_config_entry_first_refresh()
+ youless_coordinator = YouLessCoordinator(hass, api)
+ await youless_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ hass.data[DOMAIN][entry.entry_id] = youless_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py
new file mode 100644
index 00000000000..0be5e463689
--- /dev/null
+++ b/homeassistant/components/youless/coordinator.py
@@ -0,0 +1,25 @@
+"""The coordinator for the Youless integration."""
+
+from datetime import timedelta
+import logging
+
+from youless_api import YoulessAPI
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class YouLessCoordinator(DataUpdateCoordinator[None]):
+ """Class to manage fetching YouLess data."""
+
+ def __init__(self, hass: HomeAssistant, device: YoulessAPI) -> None:
+ """Initialize global YouLess data provider."""
+ super().__init__(
+ hass, _LOGGER, name="youless_gateway", update_interval=timedelta(seconds=10)
+ )
+ self.device = device
+
+ async def _async_update_data(self) -> None:
+ await self.hass.async_add_executor_job(self.device.update)
diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py
new file mode 100644
index 00000000000..4500fe71a96
--- /dev/null
+++ b/homeassistant/components/youless/entity.py
@@ -0,0 +1,25 @@
+"""The entity for the Youless integration."""
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import YouLessCoordinator
+
+
+class YouLessEntity(CoordinatorEntity[YouLessCoordinator]):
+ """Base entity for YouLess."""
+
+ def __init__(
+ self, coordinator: YouLessCoordinator, device_group: str, device_name: str
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.device = coordinator.device
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device_group)},
+ manufacturer="YouLess",
+ model=self.device.model,
+ translation_key=device_name,
+ sw_version=self.device.firmware_version,
+ )
diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json
index 1ccc8cda0ff..9a51e0fe0d1 100644
--- a/homeassistant/components/youless/manifest.json
+++ b/homeassistant/components/youless/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/youless",
"iot_class": "local_polling",
"loggers": ["youless_api"],
- "requirements": ["youless-api==2.1.2"]
+ "requirements": ["youless-api==2.2.0"]
}
diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py
index ed0fc703cc4..3afb215ed5f 100644
--- a/homeassistant/components/youless/sensor.py
+++ b/homeassistant/components/youless/sensor.py
@@ -2,12 +2,15 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
from youless_api import YoulessAPI
-from youless_api.youless_sensor import YoulessSensor
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
+ SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
@@ -20,346 +23,291 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
from . import DOMAIN
+from .coordinator import YouLessCoordinator
+from .entity import YouLessEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class YouLessSensorEntityDescription(SensorEntityDescription):
+ """Describes a YouLess sensor entity."""
+
+ device_group: str
+ value_func: Callable[[YoulessAPI], float | None]
+
+
+SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = (
+ YouLessSensorEntityDescription(
+ key="water",
+ device_group="water",
+ translation_key="total_water",
+ device_class=SensorDeviceClass.WATER,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ value_func=(
+ lambda device: device.water_meter.value if device.water_meter else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="gas",
+ device_group="gas",
+ translation_key="total_gas_m3",
+ device_class=SensorDeviceClass.GAS,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ value_func=lambda device: device.gas_meter.value if device.gas_meter else None,
+ ),
+ YouLessSensorEntityDescription(
+ key="usage",
+ device_group="power",
+ translation_key="active_power_w",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_func=(
+ lambda device: device.current_power_usage.value
+ if device.current_power_usage
+ else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="power_low",
+ device_group="power",
+ translation_key="total_energy_import_tariff_kwh",
+ translation_placeholders={"tariff": "1"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_func=(
+ lambda device: device.power_meter.low.value if device.power_meter else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="power_high",
+ device_group="power",
+ translation_key="total_energy_import_tariff_kwh",
+ translation_placeholders={"tariff": "2"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_func=(
+ lambda device: device.power_meter.high.value if device.power_meter else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="power_total",
+ device_group="power",
+ translation_key="total_energy_import_kwh",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_func=(
+ lambda device: device.power_meter.total.value
+ if device.power_meter
+ else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_1_power",
+ device_group="power",
+ translation_key="active_power_phase_w",
+ translation_placeholders={"phase": "1"},
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_func=lambda device: device.phase1.power.value if device.phase1 else None,
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_1_voltage",
+ device_group="power",
+ translation_key="active_voltage_phase_v",
+ translation_placeholders={"phase": "1"},
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ value_func=(
+ lambda device: device.phase1.voltage.value if device.phase1 else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_1_current",
+ device_group="power",
+ translation_key="active_current_phase_a",
+ translation_placeholders={"phase": "1"},
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_func=(
+ lambda device: device.phase1.current.value if device.phase1 else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_2_power",
+ device_group="power",
+ translation_key="active_power_phase_w",
+ translation_placeholders={"phase": "2"},
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_func=lambda device: device.phase2.power.value if device.phase2 else None,
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_2_voltage",
+ device_group="power",
+ translation_key="active_voltage_phase_v",
+ translation_placeholders={"phase": "2"},
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ value_func=(
+ lambda device: device.phase2.voltage.value if device.phase2 else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_2_current",
+ device_group="power",
+ translation_key="active_current_phase_a",
+ translation_placeholders={"phase": "2"},
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_func=(
+ lambda device: device.phase2.current.value if device.phase1 else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_3_power",
+ device_group="power",
+ translation_key="active_power_phase_w",
+ translation_placeholders={"phase": "3"},
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_func=lambda device: device.phase3.power.value if device.phase3 else None,
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_3_voltage",
+ device_group="power",
+ translation_key="active_voltage_phase_v",
+ translation_placeholders={"phase": "3"},
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ value_func=(
+ lambda device: device.phase3.voltage.value if device.phase3 else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="phase_3_current",
+ device_group="power",
+ translation_key="active_current_phase_a",
+ translation_placeholders={"phase": "3"},
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_func=(
+ lambda device: device.phase3.current.value if device.phase1 else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="delivery_low",
+ device_group="delivery",
+ translation_key="total_energy_export_tariff_kwh",
+ translation_placeholders={"tariff": "1"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_func=(
+ lambda device: device.delivery_meter.low.value
+ if device.delivery_meter
+ else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="delivery_high",
+ device_group="delivery",
+ translation_key="total_energy_export_tariff_kwh",
+ translation_placeholders={"tariff": "2"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_func=(
+ lambda device: device.delivery_meter.high.value
+ if device.delivery_meter
+ else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="extra_total",
+ device_group="extra",
+ translation_key="total_s0_kwh",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_func=(
+ lambda device: device.extra_meter.total.value
+ if device.extra_meter
+ else None
+ ),
+ ),
+ YouLessSensorEntityDescription(
+ key="extra_usage",
+ device_group="extra",
+ translation_key="active_s0_w",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_func=(
+ lambda device: device.extra_meter.usage.value
+ if device.extra_meter
+ else None
+ ),
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Initialize the integration."""
- coordinator: DataUpdateCoordinator[YoulessAPI] = hass.data[DOMAIN][entry.entry_id]
+ coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id]
device = entry.data[CONF_DEVICE]
if (device := entry.data[CONF_DEVICE]) is None:
device = entry.entry_id
async_add_entities(
[
- WaterSensor(coordinator, device),
- GasSensor(coordinator, device),
- EnergyMeterSensor(
- coordinator, device, "low", SensorStateClass.TOTAL_INCREASING
- ),
- EnergyMeterSensor(
- coordinator, device, "high", SensorStateClass.TOTAL_INCREASING
- ),
- EnergyMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL),
- CurrentPowerSensor(coordinator, device),
- DeliveryMeterSensor(coordinator, device, "low"),
- DeliveryMeterSensor(coordinator, device, "high"),
- ExtraMeterSensor(coordinator, device, "total"),
- ExtraMeterPowerSensor(coordinator, device, "usage"),
- PhasePowerSensor(coordinator, device, 1),
- PhaseVoltageSensor(coordinator, device, 1),
- PhaseCurrentSensor(coordinator, device, 1),
- PhasePowerSensor(coordinator, device, 2),
- PhaseVoltageSensor(coordinator, device, 2),
- PhaseCurrentSensor(coordinator, device, 2),
- PhasePowerSensor(coordinator, device, 3),
- PhaseVoltageSensor(coordinator, device, 3),
- PhaseCurrentSensor(coordinator, device, 3),
+ YouLessSensor(coordinator, description, device)
+ for description in SENSOR_TYPES
]
)
-class YoulessBaseSensor(
- CoordinatorEntity[DataUpdateCoordinator[YoulessAPI]], SensorEntity
-):
- """The base sensor for Youless."""
+class YouLessSensor(YouLessEntity, SensorEntity):
+ """Representation of a Sensor."""
+
+ entity_description: YouLessSensorEntityDescription
+ _attr_has_entity_name = True
def __init__(
self,
- coordinator: DataUpdateCoordinator[YoulessAPI],
+ coordinator: YouLessCoordinator,
+ description: YouLessSensorEntityDescription,
device: str,
- device_group: str,
- friendly_name: str,
- sensor_id: str,
) -> None:
- """Create the sensor."""
- super().__init__(coordinator)
- self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{device}_{device_group}")},
- manufacturer="YouLess",
- model=self.coordinator.data.model,
- name=friendly_name,
+ """Initialize the sensor."""
+ super().__init__(
+ coordinator,
+ f"{device}_{description.device_group}",
+ description.device_group,
)
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Property to get the underlying sensor object."""
- return None
+ self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}"
+ self.entity_description = description
@property
def native_value(self) -> StateType:
- """Determine the state value, only if a sensor is initialized."""
- if self.get_sensor is None:
- return None
-
- return self.get_sensor.value
-
- @property
- def available(self) -> bool:
- """Return a flag to indicate the sensor not being available."""
- return super().available and self.get_sensor is not None
-
-
-class WaterSensor(YoulessBaseSensor):
- """The Youless Water sensor."""
-
- _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS
- _attr_device_class = SensorDeviceClass.WATER
- _attr_state_class = SensorStateClass.TOTAL_INCREASING
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str
- ) -> None:
- """Instantiate a Water sensor."""
- super().__init__(coordinator, device, "water", "Water meter", "water")
- self._attr_name = "Water usage"
- self._attr_icon = "mdi:water"
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- return self.coordinator.data.water_meter
-
-
-class GasSensor(YoulessBaseSensor):
- """The Youless gas sensor."""
-
- _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS
- _attr_device_class = SensorDeviceClass.GAS
- _attr_state_class = SensorStateClass.TOTAL_INCREASING
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str
- ) -> None:
- """Instantiate a gas sensor."""
- super().__init__(coordinator, device, "gas", "Gas meter", "gas")
- self._attr_name = "Gas usage"
- self._attr_icon = "mdi:fire"
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- return self.coordinator.data.gas_meter
-
-
-class CurrentPowerSensor(YoulessBaseSensor):
- """The current power usage sensor."""
-
- _attr_native_unit_of_measurement = UnitOfPower.WATT
- _attr_device_class = SensorDeviceClass.POWER
- _attr_state_class = SensorStateClass.MEASUREMENT
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str
- ) -> None:
- """Instantiate the usage meter."""
- super().__init__(coordinator, device, "power", "Power usage", "usage")
- self._device = device
- self._attr_name = "Power Usage"
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- return self.coordinator.data.current_power_usage
-
-
-class DeliveryMeterSensor(YoulessBaseSensor):
- """The Youless delivery meter value sensor."""
-
- _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
- _attr_device_class = SensorDeviceClass.ENERGY
- _attr_state_class = SensorStateClass.TOTAL_INCREASING
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str
- ) -> None:
- """Instantiate a delivery meter sensor."""
- super().__init__(
- coordinator, device, "delivery", "Energy delivery", f"delivery_{dev_type}"
- )
- self._type = dev_type
- self._attr_name = f"Energy delivery {dev_type}"
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- if self.coordinator.data.delivery_meter is None:
- return None
-
- return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None)
-
-
-class EnergyMeterSensor(YoulessBaseSensor):
- """The Youless low meter value sensor."""
-
- _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
- _attr_device_class = SensorDeviceClass.ENERGY
- _attr_state_class = SensorStateClass.TOTAL_INCREASING
-
- def __init__(
- self,
- coordinator: DataUpdateCoordinator[YoulessAPI],
- device: str,
- dev_type: str,
- state_class: SensorStateClass,
- ) -> None:
- """Instantiate a energy meter sensor."""
- super().__init__(
- coordinator, device, "power", "Energy usage", f"power_{dev_type}"
- )
- self._device = device
- self._type = dev_type
- self._attr_name = f"Energy {dev_type}"
- self._attr_state_class = state_class
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- if self.coordinator.data.power_meter is None:
- return None
-
- return getattr(self.coordinator.data.power_meter, f"_{self._type}", None)
-
-
-class PhasePowerSensor(YoulessBaseSensor):
- """The current power usage of a single phase."""
-
- _attr_native_unit_of_measurement = UnitOfPower.WATT
- _attr_device_class = SensorDeviceClass.POWER
- _attr_state_class = SensorStateClass.MEASUREMENT
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int
- ) -> None:
- """Initialize the power phase sensor."""
- super().__init__(
- coordinator, device, "power", "Energy usage", f"phase_{phase}_power"
- )
- self._attr_name = f"Phase {phase} power"
- self._phase = phase
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor value from the coordinator."""
- phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None)
- if phase_sensor is None:
- return None
-
- return phase_sensor.power
-
-
-class PhaseVoltageSensor(YoulessBaseSensor):
- """The current voltage of a single phase."""
-
- _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
- _attr_device_class = SensorDeviceClass.VOLTAGE
- _attr_state_class = SensorStateClass.MEASUREMENT
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int
- ) -> None:
- """Initialize the voltage phase sensor."""
- super().__init__(
- coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage"
- )
- self._attr_name = f"Phase {phase} voltage"
- self._phase = phase
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor value from the coordinator for phase voltage."""
- phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None)
- if phase_sensor is None:
- return None
-
- return phase_sensor.voltage
-
-
-class PhaseCurrentSensor(YoulessBaseSensor):
- """The current current of a single phase."""
-
- _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE
- _attr_device_class = SensorDeviceClass.CURRENT
- _attr_state_class = SensorStateClass.MEASUREMENT
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int
- ) -> None:
- """Initialize the current phase sensor."""
- super().__init__(
- coordinator, device, "power", "Energy usage", f"phase_{phase}_current"
- )
- self._attr_name = f"Phase {phase} current"
- self._phase = phase
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor value from the coordinator for phase current."""
- phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None)
- if phase_sensor is None:
- return None
-
- return phase_sensor.current
-
-
-class ExtraMeterSensor(YoulessBaseSensor):
- """The Youless extra meter value sensor (s0)."""
-
- _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
- _attr_device_class = SensorDeviceClass.ENERGY
- _attr_state_class = SensorStateClass.TOTAL_INCREASING
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str
- ) -> None:
- """Instantiate an extra meter sensor."""
- super().__init__(
- coordinator, device, "extra", "Extra meter", f"extra_{dev_type}"
- )
- self._type = dev_type
- self._attr_name = f"Extra {dev_type}"
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- if self.coordinator.data.extra_meter is None:
- return None
-
- return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None)
-
-
-class ExtraMeterPowerSensor(YoulessBaseSensor):
- """The Youless extra meter power value sensor (s0)."""
-
- _attr_native_unit_of_measurement = UnitOfPower.WATT
- _attr_device_class = SensorDeviceClass.POWER
- _attr_state_class = SensorStateClass.MEASUREMENT
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str
- ) -> None:
- """Instantiate an extra meter power sensor."""
- super().__init__(
- coordinator, device, "extra", "Extra meter", f"extra_{dev_type}"
- )
- self._type = dev_type
- self._attr_name = f"Extra {dev_type}"
-
- @property
- def get_sensor(self) -> YoulessSensor | None:
- """Get the sensor for providing the value."""
- if self.coordinator.data.extra_meter is None:
- return None
-
- return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None)
+ """Return the state of the sensor."""
+ return self.entity_description.value_func(self.device)
diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json
index e0eddd7d137..8a3f6cb5d8b 100644
--- a/homeassistant/components/youless/strings.json
+++ b/homeassistant/components/youless/strings.json
@@ -14,5 +14,59 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
+ },
+ "device": {
+ "water": {
+ "name": "Water meter"
+ },
+ "gas": {
+ "name": "Gas meter"
+ },
+ "power": {
+ "name": "Power meter"
+ },
+ "delivery": {
+ "name": "Energy delivery meter"
+ },
+ "extra": {
+ "name": "S0 meter"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "total_water": {
+ "name": "Total water usage"
+ },
+ "total_gas_m3": {
+ "name": "Total gas usage"
+ },
+ "active_power_w": {
+ "name": "Current power usage"
+ },
+ "active_power_phase_w": {
+ "name": "Power phase {phase}"
+ },
+ "active_voltage_phase_v": {
+ "name": "Voltage phase {phase}"
+ },
+ "active_current_phase_a": {
+ "name": "Current phase {phase}"
+ },
+ "total_energy_import_tariff_kwh": {
+ "name": "Energy import tariff {tariff}"
+ },
+ "total_energy_import_kwh": {
+ "name": "Total energy import"
+ },
+ "total_energy_export_tariff_kwh": {
+ "name": "Energy export tariff {tariff}"
+ },
+ "total_s0_kwh": {
+ "name": "Total energy"
+ },
+ "active_s0_w": {
+ "name": "Current usage"
+ }
+ }
}
}
diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py
index 8460a105fcb..aee4b83508c 100644
--- a/homeassistant/components/youtube/__init__.py
+++ b/homeassistant/components/youtube/__init__.py
@@ -8,11 +8,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
-import homeassistant.helpers.device_registry as dr
from .api import AsyncConfigEntryAuth
from .const import AUTH, COORDINATOR, DOMAIN
diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py
index 05881d649cf..524bac271de 100644
--- a/homeassistant/components/zabbix/__init__.py
+++ b/homeassistant/components/zabbix/__init__.py
@@ -27,8 +27,11 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
-from homeassistant.helpers import event as event_helper, state as state_helper
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ event as event_helper,
+ state as state_helper,
+)
from homeassistant.helpers.entityfilter import (
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
convert_include_exclude_filter,
diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py
index 7728233ebc0..27d7e71d8d9 100644
--- a/homeassistant/components/zabbix/sensor.py
+++ b/homeassistant/components/zabbix/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py
index 69b7c63476a..2ab46820b56 100644
--- a/homeassistant/components/zengge/light.py
+++ b/homeassistant/components/zengge/light.py
@@ -18,10 +18,10 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index 449c2ccef91..b748006336c 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -4,9 +4,8 @@ from __future__ import annotations
import contextlib
from contextlib import suppress
-from dataclasses import dataclass
from fnmatch import translate
-from functools import lru_cache
+from functools import lru_cache, partial
from ipaddress import IPv4Address, IPv6Address
import logging
import re
@@ -30,12 +29,20 @@ from homeassistant.const import (
__version__,
)
from homeassistant.core import Event, HomeAssistant, callback
-from homeassistant.data_entry_flow import BaseServiceInfo
-from homeassistant.helpers import discovery_flow, instance_id
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstant,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.network import NoURLAvailableError, get_url
+from homeassistant.helpers.service_info.zeroconf import (
+ ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID,
+ ZeroconfServiceInfo as _ZeroconfServiceInfo,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import (
HomeKitDiscoveredIntegration,
@@ -83,7 +90,11 @@ ATTR_NAME: Final = "name"
ATTR_PROPERTIES: Final = "properties"
# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES]
-ATTR_PROPERTIES_ID: Final = "id"
+_DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant(
+ _ATTR_PROPERTIES_ID,
+ "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID",
+ "2026.2",
+)
CONFIG_SCHEMA = vol.Schema(
{
@@ -101,60 +112,36 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-
-@dataclass(slots=True)
-class ZeroconfServiceInfo(BaseServiceInfo):
- """Prepared info from mDNS entries.
-
- The ip_address is the most recently updated address
- that is not a link local or unspecified address.
-
- The ip_addresses are all addresses in order of most
- recently updated to least recently updated.
-
- The host is the string representation of the ip_address.
-
- The addresses are the string representations of the
- ip_addresses.
-
- It is recommended to use the ip_address to determine
- the address to connect to as it will be the most
- recently updated address that is not a link local
- or unspecified address.
- """
-
- ip_address: IPv4Address | IPv6Address
- ip_addresses: list[IPv4Address | IPv6Address]
- port: int | None
- hostname: str
- type: str
- name: str
- properties: dict[str, Any]
-
- @property
- def host(self) -> str:
- """Return the host."""
- return str(self.ip_address)
-
- @property
- def addresses(self) -> list[str]:
- """Return the addresses."""
- return [str(ip_address) for ip_address in self.ip_addresses]
+_DEPRECATED_ZeroconfServiceInfo = DeprecatedConstant(
+ _ZeroconfServiceInfo,
+ "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo",
+ "2026.2",
+)
@bind_hass
async def async_get_instance(hass: HomeAssistant) -> HaZeroconf:
- """Zeroconf instance to be shared with other integrations that use it."""
- return cast(HaZeroconf, (await _async_get_instance(hass)).zeroconf)
+ """Get or create the shared HaZeroconf instance."""
+ return cast(HaZeroconf, (_async_get_instance(hass)).zeroconf)
@bind_hass
async def async_get_async_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
- """Zeroconf instance to be shared with other integrations that use it."""
- return await _async_get_instance(hass)
+ """Get or create the shared HaAsyncZeroconf instance."""
+ return _async_get_instance(hass)
-async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf:
+@callback
+def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf:
+ """Get or create the shared HaAsyncZeroconf instance.
+
+ This method must be run in the event loop, and is an alternative
+ to the async_get_async_instance method when a coroutine cannot be used.
+ """
+ return _async_get_instance(hass)
+
+
+def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf:
if DOMAIN in hass.data:
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
@@ -221,7 +208,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
]
- aio_zc = await _async_get_instance(hass, **zc_args)
+ aio_zc = _async_get_instance(hass, **zc_args)
zeroconf = cast(HaZeroconf, aio_zc.zeroconf)
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)
@@ -409,7 +396,7 @@ class ZeroconfDiscovery:
def _async_dismiss_discoveries(self, name: str) -> None:
"""Dismiss all discoveries for the given name."""
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type(
- ZeroconfServiceInfo,
+ _ZeroconfServiceInfo,
lambda service_info: bool(service_info.name == name),
):
self.hass.config_entries.flow.async_abort(flow["flow_id"])
@@ -585,7 +572,7 @@ def async_get_homekit_discovery(
return None
-def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None:
+def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None:
"""Return prepared info from mDNS entries."""
# See https://ietf.org/rfc/rfc6763.html#section-6.4 and
# https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings
@@ -605,10 +592,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None:
return None
if TYPE_CHECKING:
- assert (
- service.server is not None
- ), "server cannot be none if there are addresses"
- return ZeroconfServiceInfo(
+ assert service.server is not None, (
+ "server cannot be none if there are addresses"
+ )
+ return _ZeroconfServiceInfo(
ip_address=ip_address,
ip_addresses=ip_addresses,
port=service.port,
@@ -674,3 +661,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool:
since the devices will not change frequently
"""
return bool(_compile_fnmatch(pattern).match(name))
+
+
+# These can be removed if no deprecated constant are in this module anymore
+__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
+__dir__ = partial(
+ dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
+)
+__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index 9ad92bb4bc7..f4a78cd99e9 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.136.2"]
+ "requirements": ["zeroconf==0.143.0"]
}
diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py
index ed6ed03ad27..36a964a46ab 100644
--- a/homeassistant/components/zerproc/light.py
+++ b/homeassistant/components/zerproc/light.py
@@ -20,7 +20,7 @@ from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py
index 12831c96932..ec8850b187d 100644
--- a/homeassistant/components/zestimate/sensor.py
+++ b/homeassistant/components/zestimate/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
index 1897b741d87..28f029b62d5 100644
--- a/homeassistant/components/zha/__init__.py
+++ b/homeassistant/components/zha/__init__.py
@@ -21,8 +21,7 @@ from homeassistant.const import (
)
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
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
index 9c515c315b7..d41ae7dbfee 100644
--- a/homeassistant/components/zha/config_flow.py
+++ b/homeassistant/components/zha/config_flow.py
@@ -14,7 +14,7 @@ from zha.application.const import RadioType
import zigpy.backups
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
-from homeassistant.components import onboarding, usb, zeroconf
+from homeassistant.components import onboarding, usb
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonState
from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
@@ -35,6 +35,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util import dt as dt_util
from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
@@ -102,7 +104,8 @@ def _format_backup_choice(
async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
"""List all serial ports, including the Yellow radio and the multi-PAN addon."""
- ports = await hass.async_add_executor_job(serial.tools.list_ports.comports)
+ ports: list[ListPortInfo] = []
+ ports.extend(await hass.async_add_executor_job(serial.tools.list_ports.comports))
# Add useful info to the Yellow's serial port selection screen
try:
@@ -585,9 +588,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
description_placeholders={CONF_NAME: self._title},
)
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
vid = discovery_info.vid
pid = discovery_info.pid
@@ -622,7 +623,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
async def async_step_zeroconf(
- self, discovery_info: zeroconf.ZeroconfServiceInfo
+ self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
@@ -648,7 +649,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
fallback_title = name.split("._", 1)[0]
title = discovery_info.properties.get("name", fallback_title)
- discovery_info = zeroconf.ZeroconfServiceInfo(
+ discovery_info = ZeroconfServiceInfo(
ip_address=discovery_info.ip_address,
ip_addresses=discovery_info.ip_addresses,
port=port,
diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py
index fc374f6c44d..7bdfc54c986 100644
--- a/homeassistant/components/zha/device_tracker.py
+++ b/homeassistant/components/zha/device_tracker.py
@@ -61,7 +61,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZHAEntity):
"""
return self.entity_data.entity.battery_level
- @property # type: ignore[explicit-override, misc]
+ @property # type: ignore[misc]
def device_info(self) -> DeviceInfo:
"""Return device info."""
# We opt ZHA device tracker back into overriding this method because
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index 77ba048312a..499721722fa 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -8,7 +8,7 @@ from functools import partial
import logging
from typing import Any
-from propcache import cached_property
+from propcache.api import cached_property
from zha.mixins import LogMixin
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory
diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py
index 2440e18cf53..c31627d3dc3 100644
--- a/homeassistant/components/zha/helpers.py
+++ b/homeassistant/components/zha/helpers.py
@@ -1170,7 +1170,7 @@ def async_add_entities(
# broad exception to prevent a single entity from preventing an entire platform from loading
# this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the
# alternative is adding try/catch to each entity class __init__ method with a specific exception
- except Exception: # noqa: BLE001
+ except Exception:
_LOGGER.exception(
"Error while adding entity from entity data: %s", entity_data
)
diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json
index 6ba4aab18ab..d43e213aa4a 100644
--- a/homeassistant/components/zha/icons.json
+++ b/homeassistant/components/zha/icons.json
@@ -124,6 +124,12 @@
},
"on_led_color": {
"default": "mdi:palette"
+ },
+ "device_mode": {
+ "default": "mdi:cogs"
+ },
+ "pilot_wire_mode": {
+ "default": "mdi:radiator"
}
},
"sensor": {
diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py
index 3de81e1255d..05539a063d2 100644
--- a/homeassistant/components/zha/logbook.py
+++ b/homeassistant/components/zha/logbook.py
@@ -10,7 +10,7 @@ from zha.application.const import ZHA_EVENT
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .const import DOMAIN as ZHA_DOMAIN
from .helpers import async_get_zha_device_proxy
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 975a1804853..6a42bc986e9 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -4,7 +4,7 @@
"after_dependencies": ["hassio", "onboarding", "usb"],
"codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"],
"config_flow": true,
- "dependencies": ["file_upload"],
+ "dependencies": ["file_upload", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/zha",
"iot_class": "local_polling",
"loggers": [
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
- "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.45"],
+ "requirements": ["zha==0.0.47"],
"usb": [
{
"vid": "10C4",
diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py
index 82c30b7678a..aaf156290a7 100644
--- a/homeassistant/components/zha/radio_manager.py
+++ b/homeassistant/components/zha/radio_manager.py
@@ -29,6 +29,7 @@ from zigpy.exceptions import NetworkNotFormed
from homeassistant import config_entries
from homeassistant.components import usb
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
from . import repairs
from .const import (
@@ -86,7 +87,7 @@ HARDWARE_MIGRATION_SCHEMA = vol.Schema(
vol.Required("old_discovery_info"): vol.Schema(
{
vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA,
- vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo,
+ vol.Exclusive("usb", "discovery"): UsbServiceInfo,
}
),
}
diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py
index 4d6d1ae52d8..566158eff56 100644
--- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py
+++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py
@@ -5,9 +5,10 @@ from __future__ import annotations
import enum
import logging
-from universal_silabs_flasher.const import ApplicationType
-from universal_silabs_flasher.flasher import Flasher
-
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ probe_silabs_firmware_type,
+)
from homeassistant.components.homeassistant_sky_connect import (
hardware as skyconnect_hardware,
)
@@ -74,23 +75,6 @@ def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType:
return HardwareType.OTHER
-async def probe_silabs_firmware_type(
- device: str, *, probe_methods: ApplicationType | None = None
-) -> ApplicationType | None:
- """Probe the running firmware on a Silabs device."""
- flasher = Flasher(
- device=device,
- **({"probe_methods": probe_methods} if probe_methods else {}),
- )
-
- try:
- await flasher.probe_app_type()
- except Exception: # noqa: BLE001
- _LOGGER.debug("Failed to probe application type", exc_info=True)
-
- return flasher.app_type
-
-
async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> bool:
"""Create a repair issue if the wrong type of SiLabs firmware is detected."""
# Only consider actual serial ports
diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py
index dde000b24b5..0506496f447 100644
--- a/homeassistant/components/zha/sensor.py
+++ b/homeassistant/components/zha/sensor.py
@@ -43,10 +43,18 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = {
"measurement_type",
"apparent_power_max",
"rms_current_max",
+ "rms_current_max_ph_b",
+ "rms_current_max_ph_c",
"rms_voltage_max",
+ "rms_voltage_max_ph_b",
+ "rms_voltage_max_ph_c",
"ac_frequency_max",
"power_factor_max",
+ "power_factor_max_ph_b",
+ "power_factor_max_ph_c",
"active_power_max",
+ "active_power_max_ph_b",
+ "active_power_max_ph_c",
# Smart Energy metering
"device_type",
"status",
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index da76c62e82e..c73a0989faa 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -592,6 +592,24 @@
},
"window_detection": {
"name": "Open window detection"
+ },
+ "silence_alarm": {
+ "name": "Silence alarm"
+ },
+ "preheat_active": {
+ "name": "Preheat active"
+ },
+ "fault_alarm": {
+ "name": "Fault alarm"
+ },
+ "led_indicator": {
+ "name": "LED indicator"
+ },
+ "error_or_battery_low": {
+ "name": "Error or battery low"
+ },
+ "flow_switch": {
+ "name": "Flow switch"
}
},
"button": {
@@ -612,6 +630,9 @@
},
"restart_device": {
"name": "Restart device"
+ },
+ "frost_lock_reset": {
+ "name": "Frost lock reset"
}
},
"climate": {
@@ -885,6 +906,144 @@
},
"fading_time": {
"name": "Fading time"
+ },
+ "temperature_offset": {
+ "name": "Temperature offset"
+ },
+ "humidity_offset": {
+ "name": "Humidity offset"
+ },
+ "comfort_temperature_min": {
+ "name": "Comfort temperature min"
+ },
+ "comfort_temperature_max": {
+ "name": "Comfort temperature max"
+ },
+ "comfort_humidity_min": {
+ "name": "Comfort humidity min"
+ },
+ "comfort_humidity_max": {
+ "name": "Comfort humidity max"
+ },
+ "measurement_interval": {
+ "name": "Measurement interval"
+ },
+ "on_time": {
+ "name": "On time"
+ },
+ "alarm_duration": {
+ "name": "Alarm duration"
+ },
+ "max_set": {
+ "name": "Liquid max percentage"
+ },
+ "mini_set": {
+ "name": "Liquid minimal percentage"
+ },
+ "installation_height": {
+ "name": "Height from sensor to tank bottom"
+ },
+ "liquid_depth_max": {
+ "name": "Height from sensor to liquid level"
+ },
+ "interval_time": {
+ "name": "Interval time"
+ },
+ "target_distance": {
+ "name": "Target distance"
+ },
+ "hold_delay_time": {
+ "name": "Hold delay time"
+ },
+ "breath_detection_max": {
+ "name": "Breath detection max"
+ },
+ "breath_detection_min": {
+ "name": "Breath detection min"
+ },
+ "small_move_detection_max": {
+ "name": "Small move detection max"
+ },
+ "small_move_detection_min": {
+ "name": "Small move detection min"
+ },
+ "small_move_sensitivity": {
+ "name": "Small move sensitivity"
+ },
+ "breath_sensitivity": {
+ "name": "Breath sensitivity"
+ },
+ "entry_sensitivity": {
+ "name": "Entry sensitivity"
+ },
+ "entry_distance_indentation": {
+ "name": "Entry distance indentation"
+ },
+ "illuminance_threshold": {
+ "name": "Illuminance threshold"
+ },
+ "block_time": {
+ "name": "Block time"
+ },
+ "motion_sensitivity": {
+ "name": "Motion sensitivity"
+ },
+ "radar_sensitivity": {
+ "name": "Radar sensitivity"
+ },
+ "motionless_detection": {
+ "name": "Motionless detection"
+ },
+ "motionless_sensitivity": {
+ "name": "Motionless detection sensitivity"
+ },
+ "output_time": {
+ "name": "Output time"
+ },
+ "illuminance_interval": {
+ "name": "Illuminance interval"
+ },
+ "temperature_report_interval": {
+ "name": "Temperature report interval"
+ },
+ "humidity_report_interval": {
+ "name": "Humidity report interval"
+ },
+ "alarm_temperature_max": {
+ "name": "Alarm temperature max"
+ },
+ "alarm_temperature_min": {
+ "name": "Alarm temperature min"
+ },
+ "temperature_sensitivity": {
+ "name": "Temperature sensitivity"
+ },
+ "alarm_humidity_max": {
+ "name": "Alarm humidity max"
+ },
+ "alarm_humidity_min": {
+ "name": "Alarm humidity min"
+ },
+ "humidity_sensitivity": {
+ "name": "Humidity sensitivity"
+ },
+ "deadzone_temperature": {
+ "name": "Deadzone temperature"
+ },
+ "min_temperature": {
+ "name": "Min temperature"
+ },
+ "max_temperature": {
+ "name": "Max temperature"
+ },
+ "valve_countdown": {
+ "name": "Irrigation time"
+ },
+ "quantitative_watering": {
+ "name": "Quantitative watering"
+ },
+ "valve_duration": {
+ "name": "Irrigation duration"
}
},
"select": {
@@ -1028,9 +1187,63 @@
},
"operation_mode": {
"name": "Operation mode"
+ },
+ "device_mode": {
+ "name": "Device mode"
+ },
+ "pilot_wire_mode": {
+ "name": "Pilot wire mode"
+ },
+ "alarm_ringtone": {
+ "name": "Alarm ringtone"
+ },
+ "liquid_state": {
+ "name": "Liquid state"
+ },
+ "breaker_mode": {
+ "name": "Breaker mode"
+ },
+ "breaker_status": {
+ "name": "Breaker status"
+ },
+ "status_indication": {
+ "name": "Status indication"
+ },
+ "breaker_polarity": {
+ "name": "Breaker polarity"
+ },
+ "work_mode": {
+ "name": "Work mode"
+ },
+ "presence_sensitivity": {
+ "name": "Presence sensitivity"
+ },
+ "fading_time": {
+ "name": "Fading time"
+ },
+ "display_unit": {
+ "name": "Display unit"
+ },
+ "alarm_mode": {
+ "name": "Alarm mode"
+ },
+ "alarm_volume": {
+ "name": "Alarm volume"
+ },
+ "working_day": {
+ "name": "Working day"
+ },
+ "eco_mode": {
+ "name": "Eco mode"
}
},
"sensor": {
+ "active_power_ph_b": {
+ "name": "Power phase B"
+ },
+ "active_power_ph_c": {
+ "name": "Power phase C"
+ },
"analog_input": {
"name": "Analog input"
},
@@ -1046,6 +1259,24 @@
"instantaneous_demand": {
"name": "Instantaneous demand"
},
+ "power_factor_ph_b": {
+ "name": "Power factor phase B"
+ },
+ "power_factor_ph_c": {
+ "name": "Power factor phase C"
+ },
+ "rms_current_ph_b": {
+ "name": "Current phase B"
+ },
+ "rms_current_ph_c": {
+ "name": "Current phase C"
+ },
+ "rms_voltage_ph_b": {
+ "name": "Voltage phase B"
+ },
+ "rms_voltage_ph_c": {
+ "name": "Voltage phase C"
+ },
"summation_delivered": {
"name": "Summation delivered"
},
@@ -1246,6 +1477,90 @@
},
"self_test": {
"name": "Self test result"
+ },
+ "voc_index": {
+ "name": "VOC index"
+ },
+ "energy_ph_a": {
+ "name": "Energy phase A"
+ },
+ "energy_ph_b": {
+ "name": "Energy phase B"
+ },
+ "energy_ph_c": {
+ "name": "Energy phase C"
+ },
+ "energy_produced": {
+ "name": "Energy produced"
+ },
+ "energy_produced_ph_a": {
+ "name": "Energy produced phase A"
+ },
+ "energy_produced_ph_b": {
+ "name": "Energy produced phase B"
+ },
+ "energy_produced_ph_c": {
+ "name": "Energy produced phase C"
+ },
+ "total_power_factor": {
+ "name": "Total power factor"
+ },
+ "self_test_result": {
+ "name": "Self test result"
+ },
+ "lower_explosive_limit": {
+ "name": "% Lower explosive limit"
+ },
+ "liquid_depth": {
+ "name": "Liquid depth"
+ },
+ "liquid_level_percent": {
+ "name": "Liquid level ratio"
+ },
+ "target_distance": {
+ "name": "Target distance"
+ },
+ "human_motion_state": {
+ "name": "Human motion state"
+ },
+ "temperature_alarm": {
+ "name": "Temperature alarm"
+ },
+ "humidity_alarm": {
+ "name": "Humidity alarm"
+ },
+ "alarm_state": {
+ "name": "Alarm state"
+ },
+ "power_type": {
+ "name": "Power type"
+ },
+ "valve_position": {
+ "name": "Valve position"
+ },
+ "time_left": {
+ "name": "Time left"
+ },
+ "valve_status": {
+ "name": "Valve status"
+ },
+ "valve_duration": {
+ "name": "Irrigation duration"
+ },
+ "smart_irrigation": {
+ "name": "Smart irrigation"
+ },
+ "surplus_flow": {
+ "name": "Surplus flow"
+ },
+ "single_watering_duration": {
+ "name": "Single watering duration"
+ },
+ "single_watering_amount": {
+ "name": "Single watering amount"
+ },
+ "error_status": {
+ "name": "Error status"
}
},
"switch": {
@@ -1374,6 +1689,63 @@
},
"find_switch": {
"name": "Distance switch"
+ },
+ "display_enabled": {
+ "name": "Display enabled"
+ },
+ "show_smiley": {
+ "name": "Show smiley"
+ },
+ "on_only_when_dark": {
+ "name": "On only when dark"
+ },
+ "mute_siren": {
+ "name": "Mute siren"
+ },
+ "self_test_switch": {
+ "name": "Self test"
+ },
+ "output_switch": {
+ "name": "Output switch"
+ },
+ "siren_on": {
+ "name": "Siren on"
+ },
+ "enable_tamper_alarm": {
+ "name": "Enable tamper alarm"
+ },
+ "temperature_alarm": {
+ "name": "Temperature alarm"
+ },
+ "humidity_alarm": {
+ "name": "Humidity alarm"
+ },
+ "silence_alarm": {
+ "name": "Silence alarm"
+ },
+ "frost_protection": {
+ "name": "Frost protection"
+ },
+ "factory_reset": {
+ "name": "Factory reset"
+ },
+ "away_mode": {
+ "name": "Away mode"
+ },
+ "schedule_enable": {
+ "name": "Schedule enable"
+ },
+ "scale_protection": {
+ "name": "Scale protection"
+ },
+ "frost_lock": {
+ "name": "Frost lock"
+ },
+ "switch_enabled": {
+ "name": "Switch enabled"
+ },
+ "total_flow_reset_switch": {
+ "name": "Total flow reset switch"
}
}
}
diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py
index cb5c160e7b3..2f540da5ea7 100644
--- a/homeassistant/components/zha/update.py
+++ b/homeassistant/components/zha/update.py
@@ -124,7 +124,7 @@ class ZHAFirmwareUpdateEntity(
return self.entity_data.entity.installed_version
@property
- def in_progress(self) -> bool | int | None:
+ def in_progress(self) -> bool | None:
"""Update installation progress.
Should return a boolean (True if in progress, False if not).
@@ -163,11 +163,7 @@ class ZHAFirmwareUpdateEntity(
"""
if self.entity_data.device_proxy.device.is_mains_powered:
- header = (
- ""
- f"{OTA_MESSAGE_RELIABILITY}"
- ""
- )
+ header = f"{OTA_MESSAGE_RELIABILITY}"
else:
header = (
""
diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py
index 5ffd7117d93..d562a807a4f 100644
--- a/homeassistant/components/zha/websocket_api.py
+++ b/homeassistant/components/zha/websocket_api.py
@@ -59,8 +59,7 @@ from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import VolDictType, VolSchemaType
diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py
index b5acc230472..af3287d3068 100644
--- a/homeassistant/components/zhong_hong/climate.py
+++ b/homeassistant/components/zhong_hong/climate.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py
index 6e858b454e9..fe180208801 100644
--- a/homeassistant/components/ziggo_mediabox_xl/media_player.py
+++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py
index aa4aefe6d95..af4999e5438 100644
--- a/homeassistant/components/zone/trigger.py
+++ b/homeassistant/components/zone/trigger.py
@@ -85,11 +85,8 @@ async def async_attach_trigger(
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
- if (
- from_s
- and not location.has_location(from_s)
- or to_s
- and not location.has_location(to_s)
+ if (from_s and not location.has_location(from_s)) or (
+ to_s and not location.has_location(to_s)
):
return
@@ -107,13 +104,8 @@ async def async_attach_trigger(
from_match = condition.zone(hass, zone_state, from_s) if from_s else False
to_match = condition.zone(hass, zone_state, to_s) if to_s else False
- if (
- event == EVENT_ENTER
- and not from_match
- and to_match
- or event == EVENT_LEAVE
- and from_match
- and not to_match
+ if (event == EVENT_ENTER and not from_match and to_match) or (
+ event == EVENT_LEAVE and from_match and not to_match
):
description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
hass.async_run_hass_job(
diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py
index e87a2b1531d..c2e57b0448b 100644
--- a/homeassistant/components/zoneminder/__init__.py
+++ b/homeassistant/components/zoneminder/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py
index 75769d9fd98..4f79f8876e5 100644
--- a/homeassistant/components/zoneminder/sensor.py
+++ b/homeassistant/components/zoneminder/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py
index 23adf2f4c88..13da0927196 100644
--- a/homeassistant/components/zoneminder/switch.py
+++ b/homeassistant/components/zoneminder/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py
index 1a1cd6ae9c1..37ce9a51c91 100644
--- a/homeassistant/components/zwave_js/api.py
+++ b/homeassistant/components/zwave_js/api.py
@@ -70,9 +70,8 @@ from homeassistant.components.websocket_api import (
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .config_validation import BITMASK_SCHEMA
diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py
index 711eb14070d..44adf6a12ab 100644
--- a/homeassistant/components/zwave_js/config_flow.py
+++ b/homeassistant/components/zwave_js/config_flow.py
@@ -19,7 +19,6 @@ from homeassistant.components.hassio import (
AddonManager,
AddonState,
)
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import (
SOURCE_USB,
ConfigEntriesFlowManager,
@@ -39,6 +38,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from . import disconnect_client
@@ -405,9 +406,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
},
)
- async def async_step_usb(
- self, discovery_info: usb.UsbServiceInfo
- ) -> ConfigFlowResult:
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB Discovery."""
if not is_hassio(self.hass):
return self.async_abort(reason="discovery_requires_supervisor")
diff --git a/homeassistant/components/zwave_js/config_validation.py b/homeassistant/components/zwave_js/config_validation.py
index 30bc2f16789..2615bfc72b3 100644
--- a/homeassistant/components/zwave_js/config_validation.py
+++ b/homeassistant/components/zwave_js/config_validation.py
@@ -4,7 +4,7 @@ from typing import Any
import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv
# Validates that a bitmask is provided in hex form and converts it to decimal
# int equivalent since that's what the library uses
diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py
index 5885527e01c..904a26acc78 100644
--- a/homeassistant/components/zwave_js/helpers.py
+++ b/homeassistant/components/zwave_js/helpers.py
@@ -154,16 +154,8 @@ async def async_enable_server_logging_if_needed(
LOGGER.info("Enabling zwave-js-server logging")
if (curr_server_log_level := driver.log_config.level) and (
LOG_LEVEL_MAP[curr_server_log_level]
- ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()):
+ ) > LIB_LOGGER.getEffectiveLevel():
entry_data = entry.runtime_data
- LOGGER.warning(
- (
- "Server logging is set to %s and is currently less verbose "
- "than library logging, setting server log level to %s to match"
- ),
- curr_server_log_level,
- logging.getLevelName(lib_log_level),
- )
entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level
await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG))
await driver.client.enable_server_logging()
diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py
index e6cfc6c8b29..0a2ca95a2b0 100644
--- a/homeassistant/components/zwave_js/light.py
+++ b/homeassistant/components/zwave_js/light.py
@@ -42,7 +42,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -458,7 +458,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if warm_white and cool_white:
self._supports_color_temp = True
# only one white channel (warm white or cool white) = rgbw support
- elif red and green and blue and warm_white or cool_white:
+ elif (red and green and blue and warm_white) or cool_white:
self._supports_rgbw = True
@callback
diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py
index 315793b9726..120084788e1 100644
--- a/homeassistant/components/zwave_js/logbook.py
+++ b/homeassistant/components/zwave_js/logbook.py
@@ -9,7 +9,7 @@ from zwave_js_server.const import CommandClass
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .const import (
ATTR_COMMAND_CLASS,
diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py
index d1cb66ceafc..8389eff8cb2 100644
--- a/homeassistant/components/zwave_js/services.py
+++ b/homeassistant/components/zwave_js/services.py
@@ -29,8 +29,11 @@ from zwave_js_server.util.node import (
from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import device_registry as dr, entity_registry as er
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.group import expand_entity_ids
@@ -488,10 +491,7 @@ class ZWaveServices:
)
if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING):
_LOGGER.warning(
- (
- "The following nodes do not have endpoint %x and will be "
- "skipped: %s"
- ),
+ "The following nodes do not have endpoint %x and will be skipped: %s",
endpoint,
nodes_without_endpoints,
)
diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json
index fc63b7e9119..e2d7720189d 100644
--- a/homeassistant/components/zwave_js/strings.json
+++ b/homeassistant/components/zwave_js/strings.json
@@ -1,28 +1,28 @@
{
"config": {
"abort": {
- "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.",
- "addon_info_failed": "Failed to get Z-Wave JS add-on info.",
- "addon_install_failed": "Failed to install the Z-Wave JS add-on.",
- "addon_set_config_failed": "Failed to set Z-Wave JS configuration.",
- "addon_start_failed": "Failed to start the Z-Wave JS add-on.",
+ "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.",
+ "addon_info_failed": "Failed to get Z-Wave add-on info.",
+ "addon_install_failed": "Failed to install the Z-Wave add-on.",
+ "addon_set_config_failed": "Failed to set Z-Wave configuration.",
+ "addon_start_failed": "Failed to start the Z-Wave add-on.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_requires_supervisor": "Discovery requires the supervisor.",
"not_zwave_device": "Discovered device is not a Z-Wave device.",
- "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on."
+ "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on."
},
"error": {
- "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.",
+ "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_ws_url": "Invalid websocket URL",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"progress": {
- "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.",
- "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds."
+ "install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.",
+ "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds."
},
"step": {
"configure_addon": {
@@ -34,13 +34,13 @@
"usb_path": "[%key:common::config_flow::data::usb_path%]"
},
"description": "The add-on will generate security keys if those fields are left empty.",
- "title": "Enter the Z-Wave JS add-on configuration"
+ "title": "Enter the Z-Wave add-on configuration"
},
"hassio_confirm": {
- "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on"
+ "title": "Set up Z-Wave integration with the Z-Wave add-on"
},
"install_addon": {
- "title": "The Z-Wave JS add-on installation has started"
+ "title": "The Z-Wave add-on installation has started"
},
"manual": {
"data": {
@@ -49,20 +49,20 @@
},
"on_supervisor": {
"data": {
- "use_addon": "Use the Z-Wave JS Supervisor add-on"
+ "use_addon": "Use the Z-Wave Supervisor add-on"
},
- "description": "Do you want to use the Z-Wave JS Supervisor add-on?",
+ "description": "Do you want to use the Z-Wave Supervisor add-on?",
"title": "Select connection method"
},
"start_addon": {
- "title": "The Z-Wave JS add-on is starting."
+ "title": "The Z-Wave add-on is starting."
},
"usb_confirm": {
- "description": "Do you want to set up {name} with the Z-Wave JS add-on?"
+ "description": "Do you want to set up {name} with the Z-Wave add-on?"
},
"zeroconf_confirm": {
- "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?",
- "title": "Discovered Z-Wave JS Server"
+ "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?",
+ "title": "Discovered Z-Wave Server"
}
}
},
@@ -89,7 +89,7 @@
"event.value_notification.scene_activation": "Scene Activation on {subtype}",
"state.node_status": "Node status changed",
"zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}",
- "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value"
+ "zwave_js.value_updated.value": "Value change on a Z-Wave Value"
},
"extra_fields": {
"code_slot": "Code slot",
@@ -191,7 +191,7 @@
},
"step": {
"init": {
- "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.",
+ "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.",
"menu_options": {
"confirm": "Re-interview device",
"ignore": "Ignore device config update"
@@ -203,8 +203,8 @@
"title": "Device configuration file changed: {device_name}"
},
"invalid_server_version": {
- "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue.",
- "title": "Newer version of Z-Wave JS Server needed"
+ "description": "The version of Z-Wave Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave Server to the latest version to fix this issue.",
+ "title": "Newer version of Z-Wave Server needed"
}
},
"options": {
@@ -306,7 +306,7 @@
"description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` action and require direct calls to the Command Class API.",
"fields": {
"area_id": {
- "description": "The area(s) to target for this action. If an area is specified, all zwave_js devices and entities in that area will be targeted for this action.",
+ "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.",
"name": "Area ID(s)"
},
"command_class": {
@@ -326,18 +326,18 @@
"name": "Entity ID(s)"
},
"method_name": {
- "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.",
+ "description": "The name of the API method to call. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.",
"name": "Method name"
},
"parameters": {
- "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.",
+ "description": "A list of parameters to pass to the API method. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.",
"name": "Parameters"
}
},
"name": "Invoke a Command Class API on a node (advanced)"
},
"multicast_set_value": {
- "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.",
+ "description": "Changes any value that Z-Wave recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.",
"fields": {
"area_id": {
"description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]",
@@ -383,7 +383,7 @@
"name": "Set a value on multiple devices via multicast (advanced)"
},
"ping": {
- "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.",
+ "description": "Forces Z-Wave to try to reach a node. This can be used to update the status of the node in Z-Wave when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.",
"fields": {
"area_id": {
"description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]",
@@ -474,7 +474,7 @@
"name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]"
},
"bitmask": {
- "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format.",
+ "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with 'Value size' or 'Value format'.",
"name": "Bitmask"
},
"device_id": {
@@ -498,11 +498,11 @@
"name": "Value"
},
"value_format": {
- "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.",
+ "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with 'Value size' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.",
"name": "Value format"
},
"value_size": {
- "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.",
+ "description": "Size of the value, either 1, 2, or 4. Used in combination with 'Value format' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.",
"name": "Value size"
}
},
@@ -553,10 +553,10 @@
"name": "Set lock user code"
},
"set_value": {
- "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.",
+ "description": "Changes any value that Z-Wave recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.",
"fields": {
"area_id": {
- "description": "The area(s) to target for this action. If an area is specified, all zwave_js devices and entities in that area will be targeted for this action.",
+ "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.",
"name": "Area ID(s)"
},
"command_class": {
@@ -576,7 +576,7 @@
"name": "Entity ID(s)"
},
"options": {
- "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set.",
+ "description": "Set value options map. Refer to the Z-Wave documentation for more information on what options can be set.",
"name": "Options"
},
"property": {
diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py
index 1444bfc1b95..d37d76a093b 100644
--- a/homeassistant/components/zwave_me/config_flow.py
+++ b/homeassistant/components/zwave_me/config_flow.py
@@ -7,9 +7,9 @@ import logging
from url_normalize import url_normalize
import voluptuous as vol
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_TOKEN, CONF_URL
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import helpers
from .const import DOMAIN
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index ade4cd855ca..620e4bc8197 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -22,11 +22,10 @@ from functools import cache
import logging
from random import randint
from types import MappingProxyType
-from typing import TYPE_CHECKING, Any, Generic, Self, cast
+from typing import TYPE_CHECKING, Any, Self, cast
from async_interrupt import interrupt
-from propcache import cached_property
-from typing_extensions import TypeVar
+from propcache.api import cached_property
import voluptuous as vol
from . import data_entry_flow, loader
@@ -88,12 +87,12 @@ from .util.enum import try_parse_enum
if TYPE_CHECKING:
from .components.bluetooth import BluetoothServiceInfoBleak
- from .components.dhcp import DhcpServiceInfo
- from .components.ssdp import SsdpServiceInfo
- from .components.usb import UsbServiceInfo
- from .components.zeroconf import ZeroconfServiceInfo
+ from .helpers.service_info.dhcp import DhcpServiceInfo
from .helpers.service_info.hassio import HassioServiceInfo
from .helpers.service_info.mqtt import MqttServiceInfo
+ from .helpers.service_info.ssdp import SsdpServiceInfo
+ from .helpers.service_info.usb import UsbServiceInfo
+ from .helpers.service_info.zeroconf import ZeroconfServiceInfo
_LOGGER = logging.getLogger(__name__)
@@ -137,8 +136,6 @@ DISCOVERY_COOLDOWN = 1
ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision"
UNIQUE_ID_COLLISION_TITLE_LIMIT = 5
-_DataT = TypeVar("_DataT", default=Any)
-
class ConfigEntryState(Enum):
"""Config entry state."""
@@ -313,7 +310,7 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N
)
-class ConfigEntry(Generic[_DataT]):
+class ConfigEntry[_DataT = Any]:
"""Hold a configuration entry."""
entry_id: str
@@ -691,10 +688,7 @@ class ConfigEntry(Generic[_DataT]):
self._tries += 1
ready_message = f"ready yet: {message}" if message else "ready yet"
_LOGGER.debug(
- (
- "Config entry '%s' for %s integration not %s; Retrying in %d"
- " seconds"
- ),
+ "Config entry '%s' for %s integration not %s; Retrying in %d seconds",
self.title,
self.domain,
ready_message,
@@ -1890,7 +1884,7 @@ class ConfigEntries:
def async_loaded_entries(self, domain: str) -> list[ConfigEntry]:
"""Return loaded entries for a specific domain.
- This will exclude ignored or disabled config entruis.
+ This will exclude ignored or disabled config entries.
"""
entries = self._entries.get_entries_for_domain(domain)
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 101cd2e3173..111595ea83f 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -24,14 +24,14 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
-MINOR_VERSION: Final = 1
-PATCH_VERSION: Final = "4"
+MINOR_VERSION: Final = 2
+PATCH_VERSION: Final = "0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
-REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
+REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
# Truthy date string triggers showing related deprecation warning messages.
-REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2"
+REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = ""
# Format for platform files
PLATFORM_FORMAT: Final = "{platform}.{domain}"
@@ -647,6 +647,8 @@ class UnitOfElectricPotential(StrEnum):
MICROVOLT = "µV"
MILLIVOLT = "mV"
VOLT = "V"
+ KILOVOLT = "kV"
+ MEGAVOLT = "MV"
# Degree units
diff --git a/homeassistant/core.py b/homeassistant/core.py
index da7a206b14e..46ae499e2ca 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -36,12 +36,12 @@ from typing import (
NotRequired,
Self,
TypedDict,
+ TypeVar,
cast,
overload,
)
-from propcache import cached_property, under_cached_property
-from typing_extensions import TypeVar
+from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from . import util
@@ -332,7 +332,7 @@ class HassJob[**_P, _R_co]:
we run the job.
"""
- __slots__ = ("target", "name", "_cancel_on_shutdown", "_cache")
+ __slots__ = ("_cache", "_cancel_on_shutdown", "name", "target")
def __init__(
self,
@@ -1153,8 +1153,7 @@ class HomeAssistant:
await self.async_block_till_done()
except TimeoutError:
_LOGGER.warning(
- "Timed out waiting for integrations to stop, the shutdown will"
- " continue"
+ "Timed out waiting for integrations to stop, the shutdown will continue"
)
self._async_log_running_tasks("stop integrations")
@@ -1247,7 +1246,7 @@ class HomeAssistant:
class Context:
"""The context that triggered something."""
- __slots__ = ("id", "user_id", "parent_id", "origin_event", "_cache")
+ __slots__ = ("_cache", "id", "origin_event", "parent_id", "user_id")
def __init__(
self,
@@ -1322,12 +1321,12 @@ class Event(Generic[_DataT]):
"""Representation of an event within the bus."""
__slots__ = (
- "event_type",
+ "_cache",
+ "context",
"data",
+ "event_type",
"origin",
"time_fired_timestamp",
- "context",
- "_cache",
)
def __init__(
@@ -1768,18 +1767,18 @@ class State:
"""
__slots__ = (
- "entity_id",
- "state",
+ "_cache",
"attributes",
+ "context",
+ "domain",
+ "entity_id",
"last_changed",
"last_reported",
"last_updated",
- "context",
- "state_info",
- "domain",
- "object_id",
"last_updated_timestamp",
- "_cache",
+ "object_id",
+ "state",
+ "state_info",
)
def __init__(
@@ -2067,7 +2066,7 @@ class States(UserDict[str, State]):
class StateMachine:
"""Helper class that tracks the state of different entities."""
- __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop")
+ __slots__ = ("_bus", "_loop", "_reservations", "_states", "_states_data")
def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None:
"""Initialize state machine."""
@@ -2405,7 +2404,7 @@ class SupportsResponse(enum.StrEnum):
class Service:
"""Representation of a callable service."""
- __slots__ = ["job", "schema", "domain", "service", "supports_response"]
+ __slots__ = ["domain", "job", "schema", "service", "supports_response"]
def __init__(
self,
@@ -2432,7 +2431,7 @@ class Service:
class ServiceCall:
"""Representation of a call to a service."""
- __slots__ = ("hass", "domain", "service", "data", "context", "return_response")
+ __slots__ = ("context", "data", "domain", "hass", "return_response", "service")
def __init__(
self,
@@ -2465,7 +2464,7 @@ class ServiceCall:
class ServiceRegistry:
"""Offer the services over the eventbus."""
- __slots__ = ("_services", "_hass")
+ __slots__ = ("_hass", "_services")
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a service registry."""
diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py
index 38ca07e8f31..f080705fced 100644
--- a/homeassistant/core_config.py
+++ b/homeassistant/core_config.py
@@ -509,25 +509,25 @@ class _ComponentSet(set[str]):
self._top_level_components = top_level_components
self._all_components = all_components
- def add(self, component: str) -> None:
+ def add(self, value: str) -> None:
"""Add a component to the store."""
- if "." not in component:
- self._top_level_components.add(component)
- self._all_components.add(component)
+ if "." not in value:
+ self._top_level_components.add(value)
+ self._all_components.add(value)
else:
- platform, _, domain = component.partition(".")
+ platform, _, domain = value.partition(".")
if domain in BASE_PLATFORMS:
self._all_components.add(platform)
- return super().add(component)
+ return super().add(value)
- def remove(self, component: str) -> None:
+ def remove(self, value: str) -> None:
"""Remove a component from the store."""
- if "." in component:
+ if "." in value:
raise ValueError("_ComponentSet does not support removing sub-components")
- self._top_level_components.remove(component)
- return super().remove(component)
+ self._top_level_components.remove(value)
+ return super().remove(value)
- def discard(self, component: str) -> None:
+ def discard(self, value: str) -> None:
"""Remove a component from the store."""
raise NotImplementedError("_ComponentSet does not support discard, use remove")
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 6df77443e7e..e5ee5a79922 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -12,9 +12,8 @@ from dataclasses import dataclass
from enum import StrEnum
import logging
from types import MappingProxyType
-from typing import Any, Generic, Required, TypedDict, cast
+from typing import Any, Generic, Required, TypedDict, TypeVar, cast
-from typing_extensions import TypeVar
import voluptuous as vol
from .core import HomeAssistant, callback
diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py
index 85fe55277fa..0b2d2c071c5 100644
--- a/homeassistant/exceptions.py
+++ b/homeassistant/exceptions.py
@@ -15,9 +15,9 @@ if TYPE_CHECKING:
_function_cache: dict[str, Callable[[str, str, dict[str, str] | None], str]] = {}
-def import_async_get_exception_message() -> (
- Callable[[str, str, dict[str, str] | None], str]
-):
+def import_async_get_exception_message() -> Callable[
+ [str, str, dict[str, str] | None], str
+]:
"""Return a method that can fetch a translated exception message.
Defaults to English, requires translations to already be cached.
@@ -174,7 +174,7 @@ class ConditionErrorIndex(ConditionError):
"""Yield an indented representation."""
if self.total > 1:
yield self._indent(
- indent, f"In '{self.type}' (item {self.index+1} of {self.total}):"
+ indent, f"In '{self.type}' (item {self.index + 1} of {self.total}):"
)
else:
yield self._indent(indent, f"In '{self.type}':")
diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py
index 6b3028826dc..08fe28e4df5 100644
--- a/homeassistant/generated/application_credentials.py
+++ b/homeassistant/generated/application_credentials.py
@@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [
"geocaching",
"google",
"google_assistant_sdk",
+ "google_drive",
"google_mail",
"google_photos",
"google_sheets",
@@ -24,6 +25,7 @@ APPLICATION_CREDENTIALS = [
"neato",
"nest",
"netatmo",
+ "onedrive",
"point",
"senz",
"spotify",
diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py
index a105efc2685..8a5880dcde9 100644
--- a/homeassistant/generated/bluetooth.py
+++ b/homeassistant/generated/bluetooth.py
@@ -192,6 +192,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "govee_ble",
"local_name": "GVH5127*",
},
+ {
+ "connectable": False,
+ "domain": "govee_ble",
+ "local_name": "GVH5130*",
+ },
{
"connectable": False,
"domain": "govee_ble",
@@ -431,7 +436,7 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
},
{
"domain": "led_ble",
- "local_name": "MELK-*",
+ "local_name": "LD-0003",
},
{
"domain": "medcom_ble",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 599cc43c08b..3c8a1d40dc2 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest
FLOWS = {
"helper": [
"derivative",
+ "filter",
"generic_hygrostat",
"generic_thermostat",
"group",
@@ -230,6 +231,7 @@ FLOWS = {
"google",
"google_assistant_sdk",
"google_cloud",
+ "google_drive",
"google_generative_ai_conversation",
"google_mail",
"google_photos",
@@ -255,6 +257,7 @@ FLOWS = {
"holiday",
"home_connect",
"homeassistant_sky_connect",
+ "homee",
"homekit",
"homekit_controller",
"homematicip_cloud",
@@ -278,6 +281,7 @@ FLOWS = {
"icloud",
"idasen_desk",
"ifttt",
+ "igloohome",
"imap",
"imgw_pib",
"improv_ble",
@@ -329,6 +333,7 @@ FLOWS = {
"leaone",
"led_ble",
"lektrico",
+ "letpot",
"lg_netcast",
"lg_soundbar",
"lg_thinq",
@@ -355,6 +360,8 @@ FLOWS = {
"mailgun",
"mastodon",
"matter",
+ "mcp",
+ "mcp_server",
"mealie",
"meater",
"medcom_ble",
@@ -412,6 +419,7 @@ FLOWS = {
"niko_home_control",
"nina",
"nmap_tracker",
+ "nmbs",
"nobo_hub",
"nordpool",
"notion",
@@ -428,6 +436,7 @@ FLOWS = {
"omnilogic",
"oncue",
"ondilo_ico",
+ "onedrive",
"onewire",
"onkyo",
"onvif",
@@ -447,6 +456,7 @@ FLOWS = {
"otp",
"ourgroceries",
"overkiz",
+ "overseerr",
"ovo_energy",
"owntracks",
"p1_monitor",
@@ -483,6 +493,7 @@ FLOWS = {
"pvpc_hourly_pricing",
"pyload",
"qbittorrent",
+ "qbus",
"qingping",
"qnap",
"qnap_qsw",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index 67531ceced8..3dba5a98f3c 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -61,6 +61,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "axis-e82725*",
"macaddress": "E82725*",
},
+ {
+ "domain": "balboa",
+ "registered_devices": True,
+ },
+ {
+ "domain": "balboa",
+ "macaddress": "001527*",
+ },
{
"domain": "blink",
"hostname": "blink*",
@@ -253,6 +261,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "hunter*",
"macaddress": "002674*",
},
+ {
+ "domain": "incomfort",
+ "hostname": "rfgateway",
+ "macaddress": "0004A3*",
+ },
+ {
+ "domain": "incomfort",
+ "registered_devices": True,
+ },
{
"domain": "insteon",
"macaddress": "000EF3*",
@@ -599,6 +616,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "hub*",
"macaddress": "286D97*",
},
+ {
+ "domain": "smlight",
+ "registered_devices": True,
+ },
{
"domain": "solaredge",
"hostname": "target",
@@ -1111,6 +1132,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "unifiprotect",
"macaddress": "74ACB9*",
},
+ {
+ "domain": "velux",
+ "hostname": "velux_klf*",
+ "macaddress": "646184*",
+ },
{
"domain": "verisure",
"macaddress": "0023C1*",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 005fb7f694f..cab624ecb5b 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -606,12 +606,6 @@
"config_flow": true,
"iot_class": "local_push"
},
- "azure_data_explorer": {
- "name": "Azure Data Explorer",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "cloud_push"
- },
"baf": {
"name": "Big Ass Fans",
"integration_type": "hub",
@@ -1150,6 +1144,11 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "decorquip": {
+ "name": "Decorquip Dream",
+ "integration_type": "virtual",
+ "supported_by": "motion_blinds"
+ },
"delijn": {
"name": "De Lijn",
"integration_type": "hub",
@@ -2296,6 +2295,12 @@
"iot_class": "cloud_push",
"name": "Google Cloud"
},
+ "google_drive": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "Google Drive"
+ },
"google_generative_ai_conversation": {
"integration_type": "service",
"config_flow": true,
@@ -2606,6 +2611,12 @@
"integration_type": "virtual",
"supported_by": "netatmo"
},
+ "homee": {
+ "name": "Homee",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"homematic": {
"name": "Homematic",
"integrations": {
@@ -2795,6 +2806,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "igloohome": {
+ "name": "igloohome",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"ign_sismologia": {
"name": "IGN Sismolog\u00eda",
"integration_type": "service",
@@ -2849,7 +2866,7 @@
"iot_class": "local_polling"
},
"incomfort": {
- "name": "Intergas InComfort/Intouch Lan2RF gateway",
+ "name": "Intergas gateway",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
@@ -3292,6 +3309,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "letpot": {
+ "name": "LetPot",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push"
+ },
"leviton": {
"name": "Leviton",
"iot_standards": [
@@ -3323,7 +3346,7 @@
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
- "name": "LG webOS Smart TV"
+ "name": "LG webOS TV"
}
}
},
@@ -3590,6 +3613,19 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "mcp": {
+ "name": "Model Context Protocol",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
+ "mcp_server": {
+ "name": "Model Context Protocol Server",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "local_push",
+ "single_config_entry": true
+ },
"mealie": {
"name": "Mealie",
"integration_type": "service",
@@ -3718,6 +3754,12 @@
"microsoft": {
"name": "Microsoft",
"integrations": {
+ "azure_data_explorer": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push",
+ "name": "Azure Data Explorer"
+ },
"azure_devops": {
"integration_type": "hub",
"config_flow": true,
@@ -3766,6 +3808,12 @@
"iot_class": "cloud_push",
"name": "Microsoft Teams"
},
+ "onedrive": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "OneDrive"
+ },
"xbox": {
"integration_type": "hub",
"config_flow": true,
@@ -4194,7 +4242,7 @@
"nmbs": {
"name": "NMBS",
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "cloud_polling"
},
"no_ip": {
@@ -4570,6 +4618,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "overseerr": {
+ "name": "Overseerr",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"ovo_energy": {
"name": "OVO Energy",
"integration_type": "service",
@@ -4945,6 +4999,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "qbus": {
+ "name": "Qbus",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"qingping": {
"name": "Qingping",
"integration_type": "hub",
@@ -5130,7 +5190,8 @@
"name": "Refoss",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "single_config_entry": true
},
"rejseplanen": {
"name": "Rejseplanen",
@@ -6157,7 +6218,8 @@
"name": "System Monitor",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "single_config_entry": true
},
"tado": {
"name": "Tado",
@@ -7374,7 +7436,7 @@
},
"filter": {
"integration_type": "helper",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "local_push"
},
"generic_hygrostat": {
diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py
index f73388b203c..72f160ee2ec 100644
--- a/homeassistant/generated/mqtt.py
+++ b/homeassistant/generated/mqtt.py
@@ -16,6 +16,11 @@ MQTT = {
"fully_kiosk": [
"fully/deviceInfo/+",
],
+ "qbus": [
+ "cloudapp/QBUSMQTTGW/state",
+ "cloudapp/QBUSMQTTGW/config",
+ "cloudapp/QBUSMQTTGW/+/state",
+ ],
"tasmota": [
"tasmota/discovery/#",
],
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 66c576d8840..be15d88aec2 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -522,6 +522,11 @@ ZEROCONF = {
"domain": "homekit",
},
],
+ "_homewizard._tcp.local.": [
+ {
+ "domain": "homewizard",
+ },
+ ],
"_hscp._tcp.local.": [
{
"domain": "apple_tv",
@@ -729,6 +734,11 @@ ZEROCONF = {
"domain": "octoprint",
},
],
+ "_owserver._tcp.local.": [
+ {
+ "domain": "onewire",
+ },
+ ],
"_plexmediasvr._tcp.local.": [
{
"domain": "plex",
@@ -775,6 +785,11 @@ ZEROCONF = {
},
},
],
+ "_rio._tcp.local.": [
+ {
+ "domain": "russound_rio",
+ },
+ ],
"_sideplay._tcp.local.": [
{
"domain": "ecobee",
diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py
index f01ae325875..b5f5ee9a961 100644
--- a/homeassistant/helpers/aiohttp_client.py
+++ b/homeassistant/helpers/aiohttp_client.py
@@ -9,15 +9,16 @@ import socket
from ssl import SSLContext
import sys
from types import MappingProxyType
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Self
import aiohttp
from aiohttp import web
from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT
-from aiohttp.resolver import AsyncResolver
from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout
+from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver
from homeassistant import config_entries
+from homeassistant.components import zeroconf
from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.loader import bind_hass
@@ -82,6 +83,31 @@ class HassClientResponse(aiohttp.ClientResponse):
return await super().json(*args, loads=loads, **kwargs)
+class ChunkAsyncStreamIterator:
+ """Async iterator for chunked streams.
+
+ Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields
+ bytes instead of tuple[bytes, bool].
+ """
+
+ __slots__ = ("_stream",)
+
+ def __init__(self, stream: aiohttp.StreamReader) -> None:
+ """Initialize."""
+ self._stream = stream
+
+ def __aiter__(self) -> Self:
+ """Iterate."""
+ return self
+
+ async def __anext__(self) -> bytes:
+ """Yield next chunk."""
+ rv = await self._stream.readchunk()
+ if rv == (b"", False):
+ raise StopAsyncIteration
+ return rv[0]
+
+
@callback
@bind_hass
def async_get_clientsession(
@@ -337,7 +363,7 @@ def _async_get_connector(
ssl=ssl_context,
limit=MAXIMUM_CONNECTIONS,
limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST,
- resolver=AsyncResolver(),
+ resolver=_async_make_resolver(hass),
)
connectors[connector_key] = connector
@@ -348,3 +374,8 @@ def _async_get_connector(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_connector)
return connector
+
+
+@callback
+def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver:
+ return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass))
diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py
index f74296a9fb1..5601ce4032d 100644
--- a/homeassistant/helpers/area_registry.py
+++ b/homeassistant/helpers/area_registry.py
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal, TypedDict
+from homeassistant.const import ATTR_DEVICE_CLASS
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.dt import utc_from_timestamp, utcnow
from homeassistant.util.event_type import EventType
@@ -27,9 +28,9 @@ from .typing import UNDEFINED, UndefinedType
if TYPE_CHECKING:
# mypy cannot workout _cache Protocol with dataclasses
- from propcache import cached_property as under_cached_property
+ from propcache.api import cached_property as under_cached_property
else:
- from propcache import under_cached_property
+ from propcache.api import under_cached_property
DATA_REGISTRY: HassKey[AreaRegistry] = HassKey("area_registry")
@@ -38,7 +39,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType
)
STORAGE_KEY = "core.area_registry"
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 7
+STORAGE_VERSION_MINOR = 8
class _AreaStoreData(TypedDict):
@@ -46,11 +47,13 @@ class _AreaStoreData(TypedDict):
aliases: list[str]
floor_id: str | None
+ humidity_entity_id: str | None
icon: str | None
id: str
labels: list[str]
name: str
picture: str | None
+ temperature_entity_id: str | None
created_at: str
modified_at: str
@@ -74,10 +77,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry):
aliases: set[str]
floor_id: str | None
+ humidity_entity_id: str | None
icon: str | None
id: str
labels: set[str] = field(default_factory=set)
picture: str | None
+ temperature_entity_id: str | None
_cache: dict[str, Any] = field(default_factory=dict, compare=False, init=False)
@under_cached_property
@@ -89,10 +94,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry):
"aliases": list(self.aliases),
"area_id": self.id,
"floor_id": self.floor_id,
+ "humidity_entity_id": self.humidity_entity_id,
"icon": self.icon,
"labels": list(self.labels),
"name": self.name,
"picture": self.picture,
+ "temperature_entity_id": self.temperature_entity_id,
"created_at": self.created_at.timestamp(),
"modified_at": self.modified_at.timestamp(),
}
@@ -138,11 +145,17 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]):
area["labels"] = []
if old_minor_version < 7:
- # Version 1.7 adds created_at and modiefied_at
+ # Version 1.7 adds created_at and modified_at
created_at = utc_from_timestamp(0).isoformat()
for area in old_data["areas"]:
area["created_at"] = area["modified_at"] = created_at
+ if old_minor_version < 8:
+ # Version 1.8 adds humidity_entity_id and temperature_entity_id
+ for area in old_data["areas"]:
+ area["humidity_entity_id"] = None
+ area["temperature_entity_id"] = None
+
if old_major_version > 1:
raise NotImplementedError
return old_data # type: ignore[return-value]
@@ -242,11 +255,14 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
*,
aliases: set[str] | None = None,
floor_id: str | None = None,
+ humidity_entity_id: str | None = None,
icon: str | None = None,
labels: set[str] | None = None,
picture: str | None = None,
+ temperature_entity_id: str | None = None,
) -> AreaEntry:
"""Create a new area."""
+
self.hass.verify_event_loop_thread("area_registry.async_create")
if area := self.async_get_area_by_name(name):
@@ -254,14 +270,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
f"The name {name} ({area.normalized_name}) is already in use"
)
+ if humidity_entity_id is not None:
+ _validate_humidity_entity(self.hass, humidity_entity_id)
+
+ if temperature_entity_id is not None:
+ _validate_temperature_entity(self.hass, temperature_entity_id)
+
area = AreaEntry(
aliases=aliases or set(),
floor_id=floor_id,
+ humidity_entity_id=humidity_entity_id,
icon=icon,
id=self._generate_id(name),
labels=labels or set(),
name=name,
picture=picture,
+ temperature_entity_id=temperature_entity_id,
)
area_id = area.id
self.areas[area_id] = area
@@ -298,20 +322,24 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
*,
aliases: set[str] | UndefinedType = UNDEFINED,
floor_id: str | None | UndefinedType = UNDEFINED,
+ humidity_entity_id: str | None | UndefinedType = UNDEFINED,
icon: str | None | UndefinedType = UNDEFINED,
labels: set[str] | UndefinedType = UNDEFINED,
name: str | UndefinedType = UNDEFINED,
picture: str | None | UndefinedType = UNDEFINED,
+ temperature_entity_id: str | None | UndefinedType = UNDEFINED,
) -> AreaEntry:
"""Update name of area."""
updated = self._async_update(
area_id,
aliases=aliases,
floor_id=floor_id,
+ humidity_entity_id=humidity_entity_id,
icon=icon,
labels=labels,
name=name,
picture=picture,
+ temperature_entity_id=temperature_entity_id,
)
# Since updated may be the old or the new and we always fire
# an event even if nothing has changed we cannot use async_fire_internal
@@ -330,10 +358,12 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
*,
aliases: set[str] | UndefinedType = UNDEFINED,
floor_id: str | None | UndefinedType = UNDEFINED,
+ humidity_entity_id: str | None | UndefinedType = UNDEFINED,
icon: str | None | UndefinedType = UNDEFINED,
labels: set[str] | UndefinedType = UNDEFINED,
name: str | UndefinedType = UNDEFINED,
picture: str | None | UndefinedType = UNDEFINED,
+ temperature_entity_id: str | None | UndefinedType = UNDEFINED,
) -> AreaEntry:
"""Update name of area."""
old = self.areas[area_id]
@@ -342,14 +372,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
attr_name: value
for attr_name, value in (
("aliases", aliases),
+ ("floor_id", floor_id),
+ ("humidity_entity_id", humidity_entity_id),
("icon", icon),
("labels", labels),
("picture", picture),
- ("floor_id", floor_id),
+ ("temperature_entity_id", temperature_entity_id),
)
if value is not UNDEFINED and value != getattr(old, attr_name)
}
+ if "humidity_entity_id" in new_values and humidity_entity_id is not None:
+ _validate_humidity_entity(self.hass, new_values["humidity_entity_id"])
+
+ if "temperature_entity_id" in new_values and temperature_entity_id is not None:
+ _validate_temperature_entity(self.hass, new_values["temperature_entity_id"])
+
if name is not UNDEFINED and name != old.name:
new_values["name"] = name
@@ -378,11 +416,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
areas[area["id"]] = AreaEntry(
aliases=set(area["aliases"]),
floor_id=area["floor_id"],
+ humidity_entity_id=area["humidity_entity_id"],
icon=area["icon"],
id=area["id"],
labels=set(area["labels"]),
name=area["name"],
picture=area["picture"],
+ temperature_entity_id=area["temperature_entity_id"],
created_at=datetime.fromisoformat(area["created_at"]),
modified_at=datetime.fromisoformat(area["modified_at"]),
)
@@ -398,11 +438,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
{
"aliases": list(entry.aliases),
"floor_id": entry.floor_id,
+ "humidity_entity_id": entry.humidity_entity_id,
"icon": entry.icon,
"id": entry.id,
"labels": list(entry.labels),
"name": entry.name,
"picture": entry.picture,
+ "temperature_entity_id": entry.temperature_entity_id,
"created_at": entry.created_at.isoformat(),
"modified_at": entry.modified_at.isoformat(),
}
@@ -477,3 +519,33 @@ def async_entries_for_floor(registry: AreaRegistry, floor_id: str) -> list[AreaE
def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaEntry]:
"""Return entries that match a label."""
return registry.areas.get_areas_for_label(label_id)
+
+
+def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None:
+ """Validate temperature entity."""
+ # pylint: disable=import-outside-toplevel
+ from homeassistant.components.sensor import SensorDeviceClass
+
+ if not (state := hass.states.get(entity_id)):
+ raise ValueError(f"Entity {entity_id} does not exist")
+
+ if (
+ state.domain != "sensor"
+ or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE
+ ):
+ raise ValueError(f"Entity {entity_id} is not a temperature sensor")
+
+
+def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None:
+ """Validate humidity entity."""
+ # pylint: disable=import-outside-toplevel
+ from homeassistant.components.sensor import SensorDeviceClass
+
+ if not (state := hass.states.get(entity_id)):
+ raise ValueError(f"Entity {entity_id} does not exist")
+
+ if (
+ state.domain != "sensor"
+ or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY
+ ):
+ raise ValueError(f"Entity {entity_id} is not a humidity sensor")
diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py
index 4b5e2f277a0..0841585e1a1 100644
--- a/homeassistant/helpers/check_config.py
+++ b/homeassistant/helpers/check_config.py
@@ -29,7 +29,7 @@ from homeassistant.requirements import (
async_clear_install_history,
async_get_integration_with_requirements,
)
-import homeassistant.util.yaml.loader as yaml_loader
+from homeassistant.util.yaml import loader as yaml_loader
from . import config_validation as cv
from .typing import ConfigType
@@ -220,7 +220,7 @@ async def async_check_ha_config_file( # noqa: C901
except (vol.Invalid, HomeAssistantError) as ex:
_comp_error(ex, domain, config, config[domain])
continue
- except Exception as err: # noqa: BLE001
+ except Exception as err:
logging.getLogger(__name__).exception(
"Unexpected error validating config"
)
diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py
index 86d3450c3a0..aef673cb500 100644
--- a/homeassistant/helpers/collection.py
+++ b/homeassistant/helpers/collection.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from abc import ABC, abstractmethod
+from abc import abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Coroutine, Iterable
from dataclasses import dataclass
@@ -11,9 +11,8 @@ from hashlib import md5
from itertools import groupby
import logging
from operator import attrgetter
-from typing import Any, Generic, TypedDict
+from typing import Any, TypedDict
-from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -37,8 +36,6 @@ CHANGE_ADDED = "added"
CHANGE_UPDATED = "updated"
CHANGE_REMOVED = "removed"
-_EntityT = TypeVar("_EntityT", bound=Entity, default=Entity)
-
@dataclass(slots=True)
class CollectionChange:
@@ -129,7 +126,7 @@ class CollectionEntity(Entity):
"""Handle updated configuration."""
-class ObservableCollection[_ItemT](ABC):
+class ObservableCollection[_ItemT]:
"""Base collection type that can be observed."""
def __init__(self, id_manager: IDManager | None) -> None:
@@ -448,7 +445,7 @@ _GROUP_BY_KEY = attrgetter("change_type")
@dataclass(slots=True, frozen=True)
-class _CollectionLifeCycle(Generic[_EntityT]):
+class _CollectionLifeCycle[_EntityT: Entity = Entity]:
"""Life cycle for a collection of entities."""
domain: str
@@ -523,7 +520,7 @@ class _CollectionLifeCycle(Generic[_EntityT]):
@callback
-def sync_entity_lifecycle(
+def sync_entity_lifecycle[_EntityT: Entity = Entity](
hass: HomeAssistant,
domain: str,
platform: str,
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 5952e28a1eb..fa2dd42589b 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -56,8 +56,8 @@ from homeassistant.exceptions import (
TemplateError,
)
from homeassistant.loader import IntegrationNotFound, async_get_integration
+from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
-import homeassistant.util.dt as dt_util
from . import config_validation as cv, entity_registry as er
from .sun import get_astral_event_date
@@ -884,10 +884,8 @@ def time(
condition_trace_update_result(weekday=weekday, now_weekday=now_weekday)
if (
- isinstance(weekday, str)
- and weekday != now_weekday
- or now_weekday not in weekday
- ):
+ isinstance(weekday, str) and weekday != now_weekday
+ ) or now_weekday not in weekday:
return False
return True
diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py
index 60f2cd6e1a1..45e2e7cf35f 100644
--- a/homeassistant/helpers/config_entry_flow.py
+++ b/homeassistant/helpers/config_entry_flow.py
@@ -16,11 +16,11 @@ if TYPE_CHECKING:
import asyncio
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
- from homeassistant.components.dhcp import DhcpServiceInfo
- from homeassistant.components.ssdp import SsdpServiceInfo
- from homeassistant.components.zeroconf import ZeroconfServiceInfo
+ from .service_info.dhcp import DhcpServiceInfo
from .service_info.mqtt import MqttServiceInfo
+ from .service_info.ssdp import SsdpServiceInfo
+ from .service_info.zeroconf import ZeroconfServiceInfo
type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R]
diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py
index c2a61335769..24a9de5b562 100644
--- a/homeassistant/helpers/config_entry_oauth2_flow.py
+++ b/homeassistant/helpers/config_entry_oauth2_flow.py
@@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30
OAUTH_TOKEN_TIMEOUT_SEC = 30
+@callback
+def async_get_redirect_uri(hass: HomeAssistant) -> str:
+ """Return the redirect uri."""
+ if "my" in hass.config.components:
+ return MY_AUTH_CALLBACK_PATH
+
+ if (req := http.current_request.get()) is None:
+ raise RuntimeError("No current request in context")
+
+ if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None:
+ raise RuntimeError("No header in request")
+
+ return f"{ha_host}{AUTH_CALLBACK_PATH}"
+
+
class AbstractOAuth2Implementation(ABC):
"""Base class to abstract OAuth2 authentication."""
@@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
@property
def redirect_uri(self) -> str:
"""Return the redirect uri."""
- if "my" in self.hass.config.components:
- return MY_AUTH_CALLBACK_PATH
-
- if (req := http.current_request.get()) is None:
- raise RuntimeError("No current request in context")
-
- if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None:
- raise RuntimeError("No header in request")
-
- return f"{ha_host}{AUTH_CALLBACK_PATH}"
+ return async_get_redirect_uri(self.hass)
@property
def extra_authorize_data(self) -> dict:
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 3681e941eee..4978158c0f6 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -1,8 +1,6 @@
"""Helpers for config validation using voluptuous."""
-# PEP 563 seems to break typing.get_type_hints when used
-# with PEP 695 syntax. Fixed in Python 3.13.
-# from __future__ import annotations
+from __future__ import annotations
from collections.abc import Callable, Hashable, Mapping
import contextlib
@@ -109,8 +107,11 @@ from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.generated import currencies
from homeassistant.generated.countries import COUNTRIES
from homeassistant.generated.languages import LANGUAGES
-from homeassistant.util import raise_if_invalid_path, slugify as util_slugify
-import homeassistant.util.dt as dt_util
+from homeassistant.util import (
+ dt as dt_util,
+ raise_if_invalid_path,
+ slugify as util_slugify,
+)
from homeassistant.util.yaml.objects import NodeStrClass
from . import script_variables as script_variables_helper, template as template_helper
@@ -354,7 +355,7 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]:
"""Wrap value in list if it is not one."""
if value is None:
return []
- return cast("list[_T]", value) if isinstance(value, list) else [value]
+ return cast(list[_T], value) if isinstance(value, list) else [value]
def entity_id(value: Any) -> str:
@@ -676,11 +677,7 @@ def string(value: Any) -> str:
raise vol.Invalid("string value is None")
# This is expected to be the most common case, so check it first.
- if (
- type(value) is str # noqa: E721
- or type(value) is NodeStrClass
- or isinstance(value, str)
- ):
+ if type(value) is str or type(value) is NodeStrClass or isinstance(value, str):
return value
if isinstance(value, template_helper.ResultWrapper):
diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py
index adb2062a8ea..b15d8b9e607 100644
--- a/homeassistant/helpers/data_entry_flow.py
+++ b/homeassistant/helpers/data_entry_flow.py
@@ -3,10 +3,9 @@
from __future__ import annotations
from http import HTTPStatus
-from typing import Any, Generic
+from typing import Any, Generic, TypeVar
from aiohttp import web
-from typing_extensions import TypeVar
import voluptuous as vol
import voluptuous_serialize
diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py
index 83555b56dcb..c46c6806d5d 100644
--- a/homeassistant/helpers/debounce.py
+++ b/homeassistant/helpers/debounce.py
@@ -146,6 +146,10 @@ class Debouncer[_R_co]:
"""Cancel any scheduled call, and prevent new runs."""
self._shutdown_requested = True
self.async_cancel()
+ # Release hard references to parent function
+ # https://github.com/home-assistant/core/issues/137237
+ self._function = None
+ self._job = None
@callback
def async_cancel(self) -> None:
diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py
index 81f7821ec79..f02c6507d02 100644
--- a/homeassistant/helpers/deprecation.py
+++ b/homeassistant/helpers/deprecation.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
-from enum import Enum, EnumType, _EnumDict
+from enum import EnumType, IntEnum, IntFlag, StrEnum, _EnumDict
import functools
import inspect
import logging
@@ -255,7 +255,7 @@ class DeprecatedConstant(NamedTuple):
class DeprecatedConstantEnum(NamedTuple):
"""Deprecated constant."""
- enum: Enum
+ enum: StrEnum | IntEnum | IntFlag
breaks_in_ha_version: str | None
@@ -306,7 +306,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A
replacement = deprecated_const.replacement
breaks_in_ha_version = deprecated_const.breaks_in_ha_version
elif isinstance(deprecated_const, DeprecatedConstantEnum):
- value = deprecated_const.enum.value
+ value = deprecated_const.enum
replacement = (
f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}"
)
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index 981430f192d..975b4a2aec9 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -24,11 +24,11 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import async_suggest_report_issue
+from homeassistant.util import uuid as uuid_util
from homeassistant.util.dt import utc_from_timestamp, utcnow
from homeassistant.util.event_type import EventType
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import format_unserializable_data
-import homeassistant.util.uuid as uuid_util
from . import storage, translation
from .debounce import Debouncer
@@ -40,13 +40,13 @@ from .typing import UNDEFINED, UndefinedType
if TYPE_CHECKING:
# mypy cannot workout _cache Protocol with attrs
- from propcache import cached_property as under_cached_property
+ from propcache.api import cached_property as under_cached_property
from homeassistant.config_entries import ConfigEntry
from . import entity_registry
else:
- from propcache import under_cached_property
+ from propcache.api import under_cached_property
_LOGGER = logging.getLogger(__name__)
@@ -958,16 +958,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new_values["config_entries"] = config_entries
old_values["config_entries"] = old.config_entries
- for attr_name, setvalue in (
- ("connections", merge_connections),
- ("identifiers", merge_identifiers),
- ):
- old_value = getattr(old, attr_name)
- # If not undefined, check if `value` contains new items.
- if setvalue is not UNDEFINED and not setvalue.issubset(old_value):
- new_values[attr_name] = old_value | setvalue
- old_values[attr_name] = old_value
-
if merge_connections is not UNDEFINED:
normalized_connections = self._validate_connections(
device_id,
diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py
index a5a790b7ce5..350ae6dbd6a 100644
--- a/homeassistant/helpers/dispatcher.py
+++ b/homeassistant/helpers/dispatcher.py
@@ -154,7 +154,7 @@ def _format_err[*_Ts](
return (
# Functions wrapped in partial do not have a __name__
- f"Exception in {getattr(target, "__name__", None) or target} "
+ f"Exception in {getattr(target, '__name__', None) or target} "
f"when dispatching '{signal}': {args}"
)
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 19076c4edc0..2b9f2d7069e 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -18,7 +18,7 @@ import time
from types import FunctionType
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict, final
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.const import (
@@ -1028,7 +1028,7 @@ class Entity(
return STATE_UNAVAILABLE
if (state := self.state) is None:
return STATE_UNKNOWN
- if type(state) is str: # noqa: E721
+ if type(state) is str:
# fast path for strings
return state
if isinstance(state, float):
@@ -1480,9 +1480,9 @@ class Entity(
if self.registry_entry is not None:
# This is an assert as it should never happen, but helps in tests
- assert (
- not self.registry_entry.disabled_by
- ), f"Entity '{self.entity_id}' is being added while it's disabled"
+ assert not self.registry_entry.disabled_by, (
+ f"Entity '{self.entity_id}' is being added while it's disabled"
+ )
self.async_on_remove(
async_track_entity_registry_updated_event(
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index 1be7289401c..02508e9ee9e 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -7,9 +7,7 @@ from collections.abc import Callable, Iterable
from datetime import timedelta
import logging
from types import ModuleType
-from typing import Any, Generic
-
-from typing_extensions import TypeVar
+from typing import Any
from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry
@@ -39,8 +37,6 @@ from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType
DEFAULT_SCAN_INTERVAL = timedelta(seconds=15)
DATA_INSTANCES = "entity_components"
-_EntityT = TypeVar("_EntityT", bound=entity.Entity, default=entity.Entity)
-
@bind_hass
async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None:
@@ -64,7 +60,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None:
await entity_obj.async_update_ha_state(True)
-class EntityComponent(Generic[_EntityT]):
+class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
"""The EntityComponent manages platforms that manage entities.
An example of an entity component is 'light', which manages platforms such
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 0d7614c569c..c8cc6979226 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -426,11 +426,12 @@ class EntityPlatform:
type(exc).__name__,
)
return False
- except Exception:
+ except Exception as exc:
logger.exception(
- "Error while setting up %s platform for %s",
+ "Error while setting up %s platform for %s: %s",
self.platform_name,
self.domain,
+ exc, # noqa: TRY401
)
return False
else:
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 9d50b7ae83b..7300b148c77 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -65,11 +65,11 @@ from .typing import UNDEFINED, UndefinedType
if TYPE_CHECKING:
# mypy cannot workout _cache Protocol with attrs
- from propcache import cached_property as under_cached_property
+ from propcache.api import cached_property as under_cached_property
from homeassistant.config_entries import ConfigEntry
else:
- from propcache import under_cached_property
+ from propcache.api import under_cached_property
DATA_REGISTRY: HassKey[EntityRegistry] = HassKey("entity_registry")
EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = EventType(
@@ -648,6 +648,8 @@ def _validate_item(
domain: str,
platform: str,
*,
+ config_entry_id: str | None | UndefinedType = None,
+ device_id: str | None | UndefinedType = None,
disabled_by: RegistryEntryDisabler | None | UndefinedType = None,
entity_category: EntityCategory | None | UndefinedType = None,
hidden_by: RegistryEntryHider | None | UndefinedType = None,
@@ -665,12 +667,21 @@ def _validate_item(
# In HA Core 2025.10, we should fail if unique_id is not a string
report_issue = async_suggest_report_issue(hass, integration_domain=platform)
_LOGGER.error(
- ("'%s' from integration %s has a non string unique_id" " '%s', please %s"),
+ "'%s' from integration %s has a non string unique_id '%s', please %s",
domain,
platform,
unique_id,
report_issue,
)
+ if config_entry_id and config_entry_id is not UNDEFINED:
+ if not hass.config_entries.async_get_entry(config_entry_id):
+ raise ValueError(
+ f"Can't link entity to unknown config entry {config_entry_id}"
+ )
+ if device_id and device_id is not UNDEFINED:
+ device_registry = dr.async_get(hass)
+ if not device_registry.async_get(device_id):
+ raise ValueError(f"Device {device_id} does not exist")
if (
disabled_by
and disabled_by is not UNDEFINED
@@ -794,7 +805,7 @@ class EntityRegistry(BaseRegistry):
tries += 1
len_suffix = len(str(tries)) + 1
test_string = (
- f"{preferred_string[:MAX_LENGTH_STATE_ENTITY_ID-len_suffix]}_{tries}"
+ f"{preferred_string[: MAX_LENGTH_STATE_ENTITY_ID - len_suffix]}_{tries}"
)
return test_string
@@ -859,6 +870,8 @@ class EntityRegistry(BaseRegistry):
self.hass,
domain,
platform,
+ config_entry_id=config_entry_id,
+ device_id=device_id,
disabled_by=disabled_by,
entity_category=entity_category,
hidden_by=hidden_by,
@@ -1090,6 +1103,8 @@ class EntityRegistry(BaseRegistry):
self.hass,
old.domain,
old.platform,
+ config_entry_id=config_entry_id,
+ device_id=device_id,
disabled_by=disabled_by,
entity_category=entity_category,
hidden_by=hidden_by,
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 72a4ef3c050..b363bc21e86 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -951,8 +951,7 @@ def async_track_template(
if (
not isinstance(last_result, TemplateError)
and result_as_boolean(last_result)
- or not result_as_boolean(result)
- ):
+ ) or not result_as_boolean(result):
return
hass.async_run_hass_job(
diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py
index 6d03ae4ffd2..f33f8407e47 100644
--- a/homeassistant/helpers/frame.py
+++ b/homeassistant/helpers/frame.py
@@ -13,7 +13,7 @@ import sys
from types import FrameType
from typing import Any, cast
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.core import HomeAssistant, async_get_hass_or_none
from homeassistant.exceptions import HomeAssistantError
diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py
index 22f8e2acbeb..68daf5c7939 100644
--- a/homeassistant/helpers/http.py
+++ b/homeassistant/helpers/http.py
@@ -46,9 +46,9 @@ def request_handler_factory(
) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
"""Wrap the handler classes."""
is_coroutinefunction = asyncio.iscoroutinefunction(handler)
- assert is_coroutinefunction or is_callback(
- handler
- ), "Handler should be a coroutine or a callback."
+ assert is_coroutinefunction or is_callback(handler), (
+ "Handler should be a coroutine or a callback."
+ )
async def handle(request: web.Request) -> web.StreamResponse:
"""Handle incoming request."""
diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py
index c3a65943cb5..ade2ce747d5 100644
--- a/homeassistant/helpers/httpx_client.py
+++ b/homeassistant/helpers/httpx_client.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
import sys
+from types import TracebackType
from typing import Any, Self
import httpx
@@ -58,7 +59,12 @@ class HassHttpXAsyncClient(httpx.AsyncClient):
"""Prevent an integration from reopen of the client via context manager."""
return self
- async def __aexit__(self, *args: object) -> None:
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None = None,
+ exc_value: BaseException | None = None,
+ traceback: TracebackType | None = None,
+ ) -> None:
"""Prevent an integration from close of the client via context manager."""
diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py
index ce8205eb915..a8c1b0b2186 100644
--- a/homeassistant/helpers/icon.py
+++ b/homeassistant/helpers/icon.py
@@ -78,7 +78,7 @@ async def _async_get_component_icons(
class _IconsCache:
"""Cache for icons."""
- __slots__ = ("_hass", "_loaded", "_cache", "_lock")
+ __slots__ = ("_cache", "_hass", "_loaded", "_lock")
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the cache."""
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index 468539f5a9d..c93545ed414 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -12,7 +12,7 @@ from itertools import groupby
import logging
from typing import Any
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
@@ -58,6 +58,7 @@ INTENT_TIMER_STATUS = "HassTimerStatus"
INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
INTENT_RESPOND = "HassRespond"
+INTENT_BROADCAST = "HassBroadcast"
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
@@ -214,6 +215,9 @@ class MatchFailedReason(Enum):
DUPLICATE_NAME = auto()
"""Two or more entities matched the same name constraint and could not be disambiguated."""
+ MULTIPLE_TARGETS = auto()
+ """Two or more entities matched when a single target is required."""
+
def is_no_entities_reason(self) -> bool:
"""Return True if the match failed because no entities matched."""
return self not in (
@@ -254,6 +258,9 @@ class MatchTargetsConstraints:
allow_duplicate_names: bool = False
"""True if entities with duplicate names are allowed in result."""
+ single_target: bool = False
+ """True if result must contain a single target."""
+
@property
def has_constraints(self) -> bool:
"""Returns True if at least one constraint is set (ignores assistant)."""
@@ -265,6 +272,7 @@ class MatchTargetsConstraints:
or self.device_classes
or self.features
or self.states
+ or self.single_target
)
@@ -290,7 +298,7 @@ class MatchTargetsResult:
"""Reason for failed match when is_match = False."""
states: list[State] = field(default_factory=list)
- """List of matched entity states when is_match = True."""
+ """List of matched entity states."""
no_match_name: str | None = None
"""Name of invalid area/floor or duplicate name when match fails for those reasons."""
@@ -356,7 +364,6 @@ class MatchTargetsCandidate:
is_exposed: bool
entity: entity_registry.RegistryEntry | None = None
area: area_registry.AreaEntry | None = None
- floor: floor_registry.FloorEntry | None = None
device: device_registry.DeviceEntry | None = None
matched_name: str | None = None
@@ -548,6 +555,7 @@ def async_match_targets( # noqa: C901
or constraints.device_classes
or constraints.area_name
or constraints.floor_name
+ or constraints.single_target
):
if constraints.assistant:
# Check exposure
@@ -718,6 +726,48 @@ def async_match_targets( # noqa: C901
candidates = final_candidates
+ if constraints.single_target and len(candidates) > 1:
+ # Find best match using preferences
+ if not (preferences.area_id or preferences.floor_id):
+ # No preferences
+ return MatchTargetsResult(
+ False,
+ MatchFailedReason.MULTIPLE_TARGETS,
+ states=[c.state for c in candidates],
+ )
+
+ if not areas_added:
+ ar = area_registry.async_get(hass)
+ dr = device_registry.async_get(hass)
+ _add_areas(ar, dr, candidates)
+ areas_added = True
+
+ filtered_candidates: list[MatchTargetsCandidate] = candidates
+ if preferences.area_id:
+ # Filter by area
+ filtered_candidates = [
+ c for c in candidates if c.area and (c.area.id == preferences.area_id)
+ ]
+
+ if (len(filtered_candidates) > 1) and preferences.floor_id:
+ # Filter by floor
+ filtered_candidates = [
+ c
+ for c in candidates
+ if c.area and (c.area.floor_id == preferences.floor_id)
+ ]
+
+ if len(filtered_candidates) != 1:
+ # Filtering could not restrict to a single target
+ return MatchTargetsResult(
+ False,
+ MatchFailedReason.MULTIPLE_TARGETS,
+ states=[c.state for c in candidates],
+ )
+
+ # Filtering succeeded
+ candidates = filtered_candidates
+
return MatchTargetsResult(
True,
None,
@@ -1201,17 +1251,17 @@ class Intent:
"""Hold the intent."""
__slots__ = [
+ "assistant",
+ "category",
+ "context",
+ "conversation_agent_id",
+ "device_id",
"hass",
- "platform",
"intent_type",
+ "language",
+ "platform",
"slots",
"text_input",
- "context",
- "language",
- "category",
- "assistant",
- "device_id",
- "conversation_agent_id",
]
def __init__(
diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py
index 109d363d262..1a1373e19ef 100644
--- a/homeassistant/helpers/issue_registry.py
+++ b/homeassistant/helpers/issue_registry.py
@@ -12,8 +12,8 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.const import __version__ as ha_version
from homeassistant.core import HomeAssistant, callback
+from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
-import homeassistant.util.dt as dt_util
from homeassistant.util.event_type import EventType
from homeassistant.util.hass_dict import HassKey
diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py
index ebb74856429..a97dd48bf61 100644
--- a/homeassistant/helpers/json.py
+++ b/homeassistant/helpers/json.py
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Final
import orjson
from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic
-from homeassistant.util.json import ( # noqa: F401
+from homeassistant.util.json import (
JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS,
JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS,
SerializationError,
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index 38d80d5649d..b330494a1b8 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -5,20 +5,21 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import timedelta
from decimal import Decimal
from enum import Enum
from functools import cache, partial
-from typing import Any
+from typing import Any, cast
import slugify as unicode_slug
import voluptuous as vol
from voluptuous_openapi import UNSUPPORTED, convert
-from homeassistant.components.climate import INTENT_GET_TEMPERATURE
-from homeassistant.components.conversation import (
- ConversationTraceEventType,
- async_conversation_trace_append,
+from homeassistant.components.calendar import (
+ DOMAIN as CALENDAR_DOMAIN,
+ SERVICE_GET_EVENTS,
)
+from homeassistant.components.climate import INTENT_GET_TEMPERATURE
from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.intent import async_device_supports_timers
@@ -32,7 +33,7 @@ from homeassistant.const import (
)
from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util import yaml
+from homeassistant.util import dt as dt_util, yaml as yaml_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
@@ -48,9 +49,9 @@ from . import (
)
from .singleton import singleton
-SCRIPT_PARAMETERS_CACHE: HassKey[dict[str, tuple[str | None, vol.Schema]]] = HassKey(
- "llm_script_parameters_cache"
-)
+ACTION_PARAMETERS_CACHE: HassKey[
+ dict[str, dict[str, tuple[str | None, vol.Schema]]]
+] = HassKey("llm_action_parameters_cache")
LLM_API_ASSIST = "assist"
@@ -85,7 +86,7 @@ def _async_get_apis(hass: HomeAssistant) -> dict[str, API]:
@callback
-def async_register_api(hass: HomeAssistant, api: API) -> None:
+def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]:
"""Register an API to be exposed to LLMs."""
apis = _async_get_apis(hass)
@@ -94,6 +95,13 @@ def async_register_api(hass: HomeAssistant, api: API) -> None:
apis[api.id] = api
+ @callback
+ def unregister() -> None:
+ """Unregister the API."""
+ apis.pop(api.id)
+
+ return unregister
+
async def async_get_api(
hass: HomeAssistant, api_id: str, llm_context: LLMContext
@@ -164,6 +172,12 @@ class APIInstance:
async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType:
"""Call a LLM tool, validate args and return the response."""
+ # pylint: disable=import-outside-toplevel
+ from homeassistant.components.conversation import (
+ ConversationTraceEventType,
+ async_conversation_trace_append,
+ )
+
async_conversation_trace_append(
ConversationTraceEventType.TOOL_CALL,
{"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args},
@@ -312,12 +326,21 @@ class AssistAPI(API):
def _async_get_api_prompt(
self, llm_context: LLMContext, exposed_entities: dict | None
) -> str:
- """Return the prompt for the API."""
- if not exposed_entities:
+ if not exposed_entities or not exposed_entities["entities"]:
return (
"Only if the user wants to control a device, tell them to expose entities "
"to their voice assistant in Home Assistant."
)
+ return "\n".join(
+ [
+ *self._async_get_preable(llm_context),
+ *self._async_get_exposed_entities_prompt(llm_context, exposed_entities),
+ ]
+ )
+
+ @callback
+ def _async_get_preable(self, llm_context: LLMContext) -> list[str]:
+ """Return the prompt for the API."""
prompt = [
(
@@ -357,13 +380,22 @@ class AssistAPI(API):
):
prompt.append("This device is not able to start timers.")
- if exposed_entities:
+ return prompt
+
+ @callback
+ def _async_get_exposed_entities_prompt(
+ self, llm_context: LLMContext, exposed_entities: dict | None
+ ) -> list[str]:
+ """Return the prompt for the API for exposed entities."""
+ prompt = []
+
+ if exposed_entities and exposed_entities["entities"]:
prompt.append(
"An overview of the areas and the devices in this smart home:"
)
- prompt.append(yaml.dump(list(exposed_entities.values())))
+ prompt.append(yaml_util.dump(list(exposed_entities["entities"].values())))
- return "\n".join(prompt)
+ return prompt
@callback
def _async_get_tools(
@@ -393,8 +425,9 @@ class AssistAPI(API):
exposed_domains: set[str] | None = None
if exposed_entities is not None:
exposed_domains = {
- split_entity_id(entity_id)[0] for entity_id in exposed_entities
+ info["domain"] for info in exposed_entities["entities"].values()
}
+
intent_handlers = [
intent_handler
for intent_handler in intent_handlers
@@ -407,22 +440,28 @@ class AssistAPI(API):
for intent_handler in intent_handlers
]
- if llm_context.assistant is not None:
- for state in self.hass.states.async_all(SCRIPT_DOMAIN):
- if not async_should_expose(
- self.hass, llm_context.assistant, state.entity_id
- ):
- continue
+ if exposed_entities:
+ if exposed_entities[CALENDAR_DOMAIN]:
+ names = []
+ for info in exposed_entities[CALENDAR_DOMAIN].values():
+ names.extend(info["names"].split(", "))
+ tools.append(CalendarGetEventsTool(names))
- tools.append(ScriptTool(self.hass, state.entity_id))
+ tools.extend(
+ ScriptTool(self.hass, script_entity_id)
+ for script_entity_id in exposed_entities[SCRIPT_DOMAIN]
+ )
return tools
def _get_exposed_entities(
hass: HomeAssistant, assistant: str
-) -> dict[str, dict[str, Any]]:
- """Get exposed entities."""
+) -> dict[str, dict[str, dict[str, Any]]]:
+ """Get exposed entities.
+
+ Splits out calendars and scripts.
+ """
area_registry = ar.async_get(hass)
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -443,12 +482,13 @@ def _get_exposed_entities(
}
entities = {}
+ data: dict[str, dict[str, Any]] = {
+ SCRIPT_DOMAIN: {},
+ CALENDAR_DOMAIN: {},
+ }
for state in hass.states.async_all():
- if (
- not async_should_expose(hass, assistant, state.entity_id)
- or state.domain == SCRIPT_DOMAIN
- ):
+ if not async_should_expose(hass, assistant, state.entity_id):
continue
description: str | None = None
@@ -495,9 +535,13 @@ def _get_exposed_entities(
}:
info["attributes"] = attributes
- entities[state.entity_id] = info
+ if state.domain in data:
+ data[state.domain][state.entity_id] = info
+ else:
+ entities[state.entity_id] = info
- return entities
+ data["entities"] = entities
+ return data
def _selector_serializer(schema: Any) -> Any: # noqa: C901
@@ -608,104 +652,105 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901
return {"type": "string"}
-def _get_cached_script_parameters(
- hass: HomeAssistant, entity_id: str
+def _get_cached_action_parameters(
+ hass: HomeAssistant, domain: str, action: str
) -> tuple[str | None, vol.Schema]:
- """Get script description and schema."""
- entity_registry = er.async_get(hass)
-
+ """Get action description and schema."""
description = None
parameters = vol.Schema({})
- entity_entry = entity_registry.async_get(entity_id)
- if entity_entry and entity_entry.unique_id:
- parameters_cache = hass.data.get(SCRIPT_PARAMETERS_CACHE)
- if parameters_cache is None:
- parameters_cache = hass.data[SCRIPT_PARAMETERS_CACHE] = {}
+ parameters_cache = hass.data.get(ACTION_PARAMETERS_CACHE)
- @callback
- def clear_cache(event: Event) -> None:
- """Clear script parameter cache on script reload or delete."""
- if (
- event.data[ATTR_DOMAIN] == SCRIPT_DOMAIN
- and event.data[ATTR_SERVICE] in parameters_cache
- ):
- parameters_cache.pop(event.data[ATTR_SERVICE])
+ if parameters_cache is None:
+ parameters_cache = hass.data[ACTION_PARAMETERS_CACHE] = {}
- cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache)
+ @callback
+ def clear_cache(event: Event) -> None:
+ """Clear action parameter cache on action removal."""
+ if (
+ event.data[ATTR_DOMAIN] in parameters_cache
+ and event.data[ATTR_SERVICE]
+ in parameters_cache[event.data[ATTR_DOMAIN]]
+ ):
+ parameters_cache[event.data[ATTR_DOMAIN]].pop(event.data[ATTR_SERVICE])
- @callback
- def on_homeassistant_close(event: Event) -> None:
- """Cleanup."""
- cancel()
+ cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache)
- hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close
- )
+ @callback
+ def on_homeassistant_close(event: Event) -> None:
+ """Cleanup."""
+ cancel()
- if entity_entry.unique_id in parameters_cache:
- return parameters_cache[entity_entry.unique_id]
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close)
- if service_desc := service.async_get_cached_service_description(
- hass, SCRIPT_DOMAIN, entity_entry.unique_id
- ):
- description = service_desc.get("description")
- schema: dict[vol.Marker, Any] = {}
- fields = service_desc.get("fields", {})
+ if domain in parameters_cache and action in parameters_cache[domain]:
+ return parameters_cache[domain][action]
- for field, config in fields.items():
- field_description = config.get("description")
- if not field_description:
- field_description = config.get("name")
- key: vol.Marker
- if config.get("required"):
- key = vol.Required(field, description=field_description)
- else:
- key = vol.Optional(field, description=field_description)
- if "selector" in config:
- schema[key] = selector.selector(config["selector"])
- else:
- schema[key] = cv.string
+ if action_desc := service.async_get_cached_service_description(
+ hass, domain, action
+ ):
+ description = action_desc.get("description")
+ schema: dict[vol.Marker, Any] = {}
+ fields = action_desc.get("fields", {})
- parameters = vol.Schema(schema)
+ for field, config in fields.items():
+ field_description = config.get("description")
+ if not field_description:
+ field_description = config.get("name")
+ key: vol.Marker
+ if config.get("required"):
+ key = vol.Required(field, description=field_description)
+ else:
+ key = vol.Optional(field, description=field_description)
+ if "selector" in config:
+ schema[key] = selector.selector(config["selector"])
+ else:
+ schema[key] = cv.string
- aliases: list[str] = []
- if entity_entry.name:
- aliases.append(entity_entry.name)
- if entity_entry.aliases:
- aliases.extend(entity_entry.aliases)
- if aliases:
- if description:
- description = description + ". Aliases: " + str(list(aliases))
- else:
- description = "Aliases: " + str(list(aliases))
+ parameters = vol.Schema(schema)
- parameters_cache[entity_entry.unique_id] = (description, parameters)
+ if domain == SCRIPT_DOMAIN:
+ entity_registry = er.async_get(hass)
+ if (
+ entity_id := entity_registry.async_get_entity_id(domain, domain, action)
+ ) and (entity_entry := entity_registry.async_get(entity_id)):
+ aliases: list[str] = []
+ if entity_entry.name:
+ aliases.append(entity_entry.name)
+ if entity_entry.aliases:
+ aliases.extend(entity_entry.aliases)
+ if aliases:
+ if description:
+ description = description + ". Aliases: " + str(list(aliases))
+ else:
+ description = "Aliases: " + str(list(aliases))
+
+ parameters_cache.setdefault(domain, {})[action] = (description, parameters)
return description, parameters
-class ScriptTool(Tool):
- """LLM Tool representing a Script."""
+class ActionTool(Tool):
+ """LLM Tool representing an action."""
def __init__(
self,
hass: HomeAssistant,
- script_entity_id: str,
+ domain: str,
+ action: str,
) -> None:
"""Init the class."""
- self._object_id = self.name = split_entity_id(script_entity_id)[1]
- if self.name[0].isdigit():
- self.name = "_" + self.name
-
- self.description, self.parameters = _get_cached_script_parameters(
- hass, script_entity_id
+ self._domain = domain
+ self._action = action
+ self.name = f"{domain}.{action}"
+ self.description, self.parameters = _get_cached_action_parameters(
+ hass, domain, action
)
async def async_call(
self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
) -> JsonObjectType:
- """Run the script."""
+ """Call the action."""
for field, validator in self.parameters.schema.items():
if field not in tool_input.tool_args:
@@ -737,8 +782,8 @@ class ScriptTool(Tool):
tool_input.tool_args[field] = floor
result = await hass.services.async_call(
- SCRIPT_DOMAIN,
- self._object_id,
+ self._domain,
+ self._action,
tool_input.tool_args,
context=llm_context.context,
blocking=True,
@@ -746,3 +791,93 @@ class ScriptTool(Tool):
)
return {"success": True, "result": result}
+
+
+class ScriptTool(ActionTool):
+ """LLM Tool representing a Script."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ script_entity_id: str,
+ ) -> None:
+ """Init the class."""
+ script_name = split_entity_id(script_entity_id)[1]
+
+ action = script_name
+ entity_registry = er.async_get(hass)
+ entity_entry = entity_registry.async_get(script_entity_id)
+ if entity_entry and entity_entry.unique_id:
+ action = entity_entry.unique_id
+
+ super().__init__(hass, SCRIPT_DOMAIN, action)
+
+ self.name = script_name
+ if self.name[0].isdigit():
+ self.name = "_" + self.name
+
+
+class CalendarGetEventsTool(Tool):
+ """LLM Tool allowing querying a calendar."""
+
+ name = "calendar_get_events"
+ description = (
+ "Get events from a calendar. "
+ "When asked if something happens, search the whole week. "
+ "Results are RFC 5545 which means 'end' is exclusive."
+ )
+
+ def __init__(self, calendars: list[str]) -> None:
+ """Init the get events tool."""
+ self.parameters = vol.Schema(
+ {
+ vol.Required("calendar"): vol.In(calendars),
+ vol.Required("range"): vol.In(["today", "week"]),
+ }
+ )
+
+ async def async_call(
+ self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
+ ) -> JsonObjectType:
+ """Query a calendar."""
+ data = self.parameters(tool_input.tool_args)
+ result = intent.async_match_targets(
+ hass,
+ intent.MatchTargetsConstraints(
+ name=data["calendar"],
+ domains=[CALENDAR_DOMAIN],
+ assistant=llm_context.assistant,
+ ),
+ )
+ if not result.is_match:
+ return {"success": False, "error": "Calendar not found"}
+
+ entity_id = result.states[0].entity_id
+ if data["range"] == "today":
+ start = dt_util.now()
+ end = dt_util.start_of_local_day() + timedelta(days=1)
+ elif data["range"] == "week":
+ start = dt_util.now()
+ end = dt_util.start_of_local_day() + timedelta(days=7)
+
+ service_data = {
+ "entity_id": entity_id,
+ "start_date_time": start.isoformat(),
+ "end_date_time": end.isoformat(),
+ }
+
+ service_result = await hass.services.async_call(
+ CALENDAR_DOMAIN,
+ SERVICE_GET_EVENTS,
+ service_data,
+ context=llm_context.context,
+ blocking=True,
+ return_response=True,
+ )
+
+ events = [
+ event if "T" in event["start"] else {**event, "all_day": True}
+ for event in cast(dict, service_result)[entity_id]["events"]
+ ]
+
+ return {"success": True, "result": events}
diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py
index a12de4f9029..5264869d037 100644
--- a/homeassistant/helpers/location.py
+++ b/homeassistant/helpers/location.py
@@ -7,7 +7,7 @@ import logging
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant, State
-from homeassistant.util import location as loc_util
+from homeassistant.util import location as location_util
_LOGGER = logging.getLogger(__name__)
@@ -36,7 +36,7 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State
return min(
with_location,
- key=lambda state: loc_util.distance(
+ key=lambda state: location_util.distance(
state.attributes.get(ATTR_LATITUDE),
state.attributes.get(ATTR_LONGITUDE),
latitude,
diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py
index fd1f84a85ff..78812061a03 100644
--- a/homeassistant/helpers/restore_state.py
+++ b/homeassistant/helpers/restore_state.py
@@ -10,7 +10,7 @@ from typing import Any, Self, cast
from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, State, callback, valid_entity_id
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import json_loads
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 5e866cddc79..bd3babc8793 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -16,7 +16,7 @@ from types import MappingProxyType
from typing import Any, Literal, TypedDict, cast, overload
import async_interrupt
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from homeassistant import exceptions
@@ -756,10 +756,8 @@ class _ScriptRun:
)
running_script = (
- params[CONF_DOMAIN] == "automation"
- and params[CONF_SERVICE] == "trigger"
- or params[CONF_DOMAIN] in ("python_script", "script")
- )
+ params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger"
+ ) or params[CONF_DOMAIN] in ("python_script", "script")
trace_set_result(params=params, running_script=running_script)
response_data = await self._async_run_long_action(
self._hass.async_create_task_internal(
@@ -1779,7 +1777,7 @@ class Script:
f"{self.domain}.{self.name} which is already running "
"in the current execution path; "
"Traceback (most recent call last):\n"
- f"{"\n".join(formatted_stack)}",
+ f"{'\n'.join(formatted_stack)}",
level=logging.WARNING,
)
return None
@@ -1843,7 +1841,7 @@ class Script:
def _prep_repeat_script(self, step: int) -> Script:
action = self.sequence[step]
- step_name = action.get(CONF_ALIAS, f"Repeat at step {step+1}")
+ step_name = action.get(CONF_ALIAS, f"Repeat at step {step + 1}")
sub_script = Script(
self._hass,
action[CONF_REPEAT][CONF_SEQUENCE],
@@ -1866,7 +1864,7 @@ class Script:
async def _async_prep_choose_data(self, step: int) -> _ChooseData:
action = self.sequence[step]
- step_name = action.get(CONF_ALIAS, f"Choose at step {step+1}")
+ step_name = action.get(CONF_ALIAS, f"Choose at step {step + 1}")
choices = []
for idx, choice in enumerate(action[CONF_CHOOSE], start=1):
conditions = [
@@ -1920,7 +1918,7 @@ class Script:
async def _async_prep_if_data(self, step: int) -> _IfData:
"""Prepare data for an if statement."""
action = self.sequence[step]
- step_name = action.get(CONF_ALIAS, f"If at step {step+1}")
+ step_name = action.get(CONF_ALIAS, f"If at step {step + 1}")
conditions = [
await self._async_get_condition(config) for config in action[CONF_IF]
@@ -1971,7 +1969,7 @@ class Script:
async def _async_prep_parallel_scripts(self, step: int) -> list[Script]:
action = self.sequence[step]
- step_name = action.get(CONF_ALIAS, f"Parallel action at step {step+1}")
+ step_name = action.get(CONF_ALIAS, f"Parallel action at step {step + 1}")
parallel_scripts: list[Script] = []
for idx, parallel_script in enumerate(action[CONF_PARALLEL], start=1):
parallel_name = parallel_script.get(CONF_ALIAS, f"parallel {idx}")
@@ -2003,7 +2001,7 @@ class Script:
async def _async_prep_sequence_script(self, step: int) -> Script:
"""Prepare a sequence script."""
action = self.sequence[step]
- step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}")
+ step_name = action.get(CONF_ALIAS, f"Sequence action at step {step + 1}")
sequence_script = Script(
self._hass,
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 35135010452..4873d935537 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]:
# pylint: disable-next=import-outside-toplevel
from homeassistant.components import (
alarm_control_panel,
+ assist_satellite,
calendar,
camera,
climate,
@@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]:
return {
"alarm_control_panel": alarm_control_panel,
+ "assist_satellite": assist_satellite,
"calendar": calendar,
"camera": camera,
"climate": climate,
@@ -133,8 +135,7 @@ def _validate_option_or_feature(option_or_feature: str, label: str) -> Any:
domain, enum, option = option_or_feature.split(".", 2)
except ValueError as exc:
raise vol.Invalid(
- f"Invalid {label} '{option_or_feature}', expected "
- ".."
+ f"Invalid {label} '{option_or_feature}', expected .."
) from exc
base_components = _base_components()
@@ -226,7 +227,7 @@ class ServiceParams(TypedDict):
class ServiceTargetSelector:
"""Class to hold a target selector for a service."""
- __slots__ = ("entity_ids", "device_ids", "area_ids", "floor_ids", "label_ids")
+ __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids")
def __init__(self, service_call: ServiceCall) -> None:
"""Extract ids from service call data."""
@@ -503,7 +504,7 @@ def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
@bind_hass
-def async_extract_referenced_entity_ids( # noqa: C901
+def async_extract_referenced_entity_ids(
hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
) -> SelectedEntities:
"""Extract referenced entity IDs from a service call."""
diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py
new file mode 100644
index 00000000000..47479a53a8a
--- /dev/null
+++ b/homeassistant/helpers/service_info/dhcp.py
@@ -0,0 +1,14 @@
+"""DHCP discovery data."""
+
+from dataclasses import dataclass
+
+from homeassistant.data_entry_flow import BaseServiceInfo
+
+
+@dataclass(slots=True)
+class DhcpServiceInfo(BaseServiceInfo):
+ """Prepared info from dhcp entries."""
+
+ ip: str
+ hostname: str
+ macaddress: str
diff --git a/homeassistant/helpers/service_info/ssdp.py b/homeassistant/helpers/service_info/ssdp.py
new file mode 100644
index 00000000000..4a3a5a24474
--- /dev/null
+++ b/homeassistant/helpers/service_info/ssdp.py
@@ -0,0 +1,41 @@
+"""SSDP discovery data."""
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import Any, Final
+
+from homeassistant.data_entry_flow import BaseServiceInfo
+
+# Attributes for accessing info from retrieved UPnP device description
+ATTR_ST: Final = "st"
+ATTR_NT: Final = "nt"
+ATTR_UPNP_DEVICE_TYPE: Final = "deviceType"
+ATTR_UPNP_FRIENDLY_NAME: Final = "friendlyName"
+ATTR_UPNP_MANUFACTURER: Final = "manufacturer"
+ATTR_UPNP_MANUFACTURER_URL: Final = "manufacturerURL"
+ATTR_UPNP_MODEL_DESCRIPTION: Final = "modelDescription"
+ATTR_UPNP_MODEL_NAME: Final = "modelName"
+ATTR_UPNP_MODEL_NUMBER: Final = "modelNumber"
+ATTR_UPNP_MODEL_URL: Final = "modelURL"
+ATTR_UPNP_SERIAL: Final = "serialNumber"
+ATTR_UPNP_SERVICE_LIST: Final = "serviceList"
+ATTR_UPNP_UDN: Final = "UDN"
+ATTR_UPNP_UPC: Final = "UPC"
+ATTR_UPNP_PRESENTATION_URL: Final = "presentationURL"
+
+
+@dataclass(slots=True)
+class SsdpServiceInfo(BaseServiceInfo):
+ """Prepared info from ssdp/upnp entries."""
+
+ ssdp_usn: str
+ ssdp_st: str
+ upnp: Mapping[str, Any]
+ ssdp_location: str | None = None
+ ssdp_nt: str | None = None
+ ssdp_udn: str | None = None
+ ssdp_ext: str | None = None
+ ssdp_server: str | None = None
+ ssdp_headers: Mapping[str, Any] = field(default_factory=dict)
+ ssdp_all_locations: set[str] = field(default_factory=set)
+ x_homeassistant_matching_domains: set[str] = field(default_factory=set)
diff --git a/homeassistant/helpers/service_info/usb.py b/homeassistant/helpers/service_info/usb.py
new file mode 100644
index 00000000000..c7d6f6ea143
--- /dev/null
+++ b/homeassistant/helpers/service_info/usb.py
@@ -0,0 +1,17 @@
+"""USB discovery data."""
+
+from dataclasses import dataclass
+
+from homeassistant.data_entry_flow import BaseServiceInfo
+
+
+@dataclass(slots=True)
+class UsbServiceInfo(BaseServiceInfo):
+ """Prepared info from usb entries."""
+
+ device: str
+ vid: str
+ pid: str
+ serial_number: str | None
+ manufacturer: str | None
+ description: str | None
diff --git a/homeassistant/helpers/service_info/zeroconf.py b/homeassistant/helpers/service_info/zeroconf.py
new file mode 100644
index 00000000000..a91bc5e77d9
--- /dev/null
+++ b/homeassistant/helpers/service_info/zeroconf.py
@@ -0,0 +1,50 @@
+"""Zeroconf discovery data."""
+
+from dataclasses import dataclass
+from ipaddress import IPv4Address, IPv6Address
+from typing import Any, Final
+
+from homeassistant.data_entry_flow import BaseServiceInfo
+
+# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES]
+ATTR_PROPERTIES_ID: Final = "id"
+
+
+@dataclass(slots=True)
+class ZeroconfServiceInfo(BaseServiceInfo):
+ """Prepared info from mDNS entries.
+
+ The ip_address is the most recently updated address
+ that is not a link local or unspecified address.
+
+ The ip_addresses are all addresses in order of most
+ recently updated to least recently updated.
+
+ The host is the string representation of the ip_address.
+
+ The addresses are the string representations of the
+ ip_addresses.
+
+ It is recommended to use the ip_address to determine
+ the address to connect to as it will be the most
+ recently updated address that is not a link local
+ or unspecified address.
+ """
+
+ ip_address: IPv4Address | IPv6Address
+ ip_addresses: list[IPv4Address | IPv6Address]
+ port: int | None
+ hostname: str
+ type: str
+ name: str
+ properties: dict[str, Any]
+
+ @property
+ def host(self) -> str:
+ """Return the host."""
+ return str(self.ip_address)
+
+ @property
+ def addresses(self) -> list[str]:
+ """Return the addresses."""
+ return [str(ip_address) for ip_address in self.ip_addresses]
diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py
index 20e4ee82162..075fc50b49a 100644
--- a/homeassistant/helpers/singleton.py
+++ b/homeassistant/helpers/singleton.py
@@ -3,15 +3,22 @@
from __future__ import annotations
import asyncio
-from collections.abc import Callable
+from collections.abc import Callable, Coroutine
import functools
-from typing import Any, cast, overload
+from typing import Any, Literal, assert_type, cast, overload
from homeassistant.core import HomeAssistant
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
type _FuncType[_T] = Callable[[HomeAssistant], _T]
+type _Coro[_T] = Coroutine[Any, Any, _T]
+
+
+@overload
+def singleton[_T](
+ data_key: HassKey[_T], *, async_: Literal[True]
+) -> Callable[[_FuncType[_Coro[_T]]], _FuncType[_Coro[_T]]]: ...
@overload
@@ -24,29 +31,37 @@ def singleton[_T](
def singleton[_T](data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ...
-def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]:
+def singleton[_S, _T, _U](
+ data_key: Any, *, async_: bool = False
+) -> Callable[[_FuncType[_S]], _FuncType[_S]]:
"""Decorate a function that should be called once per instance.
Result will be cached and simultaneous calls will be handled.
"""
- def wrapper(func: _FuncType[_T]) -> _FuncType[_T]:
+ @overload
+ def wrapper(func: _FuncType[_Coro[_T]]) -> _FuncType[_Coro[_T]]: ...
+
+ @overload
+ def wrapper(func: _FuncType[_U]) -> _FuncType[_U]: ...
+
+ def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]:
"""Wrap a function with caching logic."""
if not asyncio.iscoroutinefunction(func):
@functools.lru_cache(maxsize=1)
@bind_hass
@functools.wraps(func)
- def wrapped(hass: HomeAssistant) -> _T:
+ def wrapped(hass: HomeAssistant) -> _U:
if data_key not in hass.data:
hass.data[data_key] = func(hass)
- return cast(_T, hass.data[data_key])
+ return cast(_U, hass.data[data_key])
return wrapped
@bind_hass
@functools.wraps(func)
- async def async_wrapped(hass: HomeAssistant) -> Any:
+ async def async_wrapped(hass: HomeAssistant) -> _T:
if data_key not in hass.data:
evt = hass.data[data_key] = asyncio.Event()
result = await func(hass)
@@ -62,6 +77,45 @@ def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]:
return cast(_T, obj_or_evt)
- return async_wrapped # type: ignore[return-value]
+ return async_wrapped
return wrapper
+
+
+async def _test_singleton_typing(hass: HomeAssistant) -> None:
+ """Test singleton overloads work as intended.
+
+ This is tested during the mypy run. Do not move it to 'tests'!
+ """
+ # Test HassKey
+ key = HassKey[int]("key")
+
+ @singleton(key)
+ def func(hass: HomeAssistant) -> int:
+ return 2
+
+ @singleton(key, async_=True)
+ async def async_func(hass: HomeAssistant) -> int:
+ return 2
+
+ assert_type(func(hass), int)
+ assert_type(await async_func(hass), int)
+
+ # Test invalid use of 'async_' with sync function
+ @singleton(key, async_=True) # type: ignore[arg-type]
+ def func_error(hass: HomeAssistant) -> int:
+ return 2
+
+ # Test string key
+ other_key = "key"
+
+ @singleton(other_key)
+ def func2(hass: HomeAssistant) -> str:
+ return ""
+
+ @singleton(other_key)
+ async def async_func2(hass: HomeAssistant) -> str:
+ return ""
+
+ assert_type(func2(hass), str)
+ assert_type(await async_func2(hass), str)
diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py
index 080599f54d8..fe94be68763 100644
--- a/homeassistant/helpers/storage.py
+++ b/homeassistant/helpers/storage.py
@@ -13,7 +13,7 @@ import os
from pathlib import Path
from typing import Any
-from propcache import cached_property
+from propcache.api import cached_property
from homeassistant.const import (
EVENT_HOMEASSISTANT_FINAL_WRITE,
@@ -30,8 +30,7 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import bind_hass
-from homeassistant.util import json as json_util
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.file import WriteError
from homeassistant.util.hass_dict import HassKey
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 5b4a48bb07c..7866250d658 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -44,7 +44,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment
from jinja2.utils import Namespace
from lru import LRU
import orjson
-from propcache import under_cached_property
+from propcache.api import under_cached_property
import voluptuous as vol
from homeassistant.const import (
@@ -74,7 +74,7 @@ from homeassistant.loader import bind_hass
from homeassistant.util import (
convert,
dt as dt_util,
- location as loc_util,
+ location as location_util,
slugify as slugify_util,
)
from homeassistant.util.async_ import run_callback_threadsafe
@@ -386,19 +386,19 @@ class RenderInfo:
"""Holds information about a template render."""
__slots__ = (
- "template",
- "filter_lifecycle",
- "filter",
"_result",
- "is_static",
- "exception",
"all_states",
"all_states_lifecycle",
"domains",
"domains_lifecycle",
"entities",
- "rate_limit",
+ "exception",
+ "filter",
+ "filter_lifecycle",
"has_time",
+ "is_static",
+ "rate_limit",
+ "template",
)
def __init__(self, template: Template) -> None:
@@ -507,17 +507,17 @@ class Template:
__slots__ = (
"__weakref__",
- "template",
+ "_compiled",
+ "_compiled_code",
+ "_exc_info",
+ "_hash_cache",
+ "_limited",
+ "_log_fn",
+ "_renders",
+ "_strict",
"hass",
"is_static",
- "_compiled_code",
- "_compiled",
- "_exc_info",
- "_limited",
- "_strict",
- "_log_fn",
- "_hash_cache",
- "_renders",
+ "template",
)
def __init__(self, template: str, hass: HomeAssistant | None = None) -> None:
@@ -601,7 +601,7 @@ class Template:
or filter depending on hass or the state machine.
"""
if self.is_static:
- if not parse_result or self.hass and self.hass.config.legacy_templates:
+ if not parse_result or (self.hass and self.hass.config.legacy_templates):
return self.template
return self._parse_result(self.template)
assert self.hass is not None, "hass variable not set on template"
@@ -630,7 +630,7 @@ class Template:
self._renders += 1
if self.is_static:
- if not parse_result or self.hass and self.hass.config.legacy_templates:
+ if not parse_result or (self.hass and self.hass.config.legacy_templates):
return self.template
return self._parse_result(self.template)
@@ -651,7 +651,7 @@ class Template:
render_result = render_result.strip()
- if not parse_result or self.hass and self.hass.config.legacy_templates:
+ if not parse_result or (self.hass and self.hass.config.legacy_templates):
return render_result
return self._parse_result(render_result)
@@ -826,7 +826,7 @@ class Template:
)
return value if error_value is _SENTINEL else error_value
- if not parse_result or self.hass and self.hass.config.legacy_templates:
+ if not parse_result or (self.hass and self.hass.config.legacy_templates):
return render_result
return self._parse_result(render_result)
@@ -841,16 +841,16 @@ class Template:
self.ensure_valid()
assert self.hass is not None, "hass variable not set on template"
- assert (
- self._limited is None or self._limited == limited
- ), "can't change between limited and non limited template"
- assert (
- self._strict is None or self._strict == strict
- ), "can't change between strict and non strict template"
+ assert self._limited is None or self._limited == limited, (
+ "can't change between limited and non limited template"
+ )
+ assert self._strict is None or self._strict == strict, (
+ "can't change between strict and non strict template"
+ )
assert not (strict and limited), "can't combine strict and limited template"
- assert (
- self._log_fn is None or self._log_fn == log_fn
- ), "can't change custom log function"
+ assert self._log_fn is None or self._log_fn == log_fn, (
+ "can't change custom log function"
+ )
assert self._compiled_code is not None, "template code was not compiled"
self._limited = limited
@@ -991,7 +991,7 @@ class StateTranslated:
class DomainStates:
"""Class to expose a specific HA domain as attributes."""
- __slots__ = ("_hass", "_domain")
+ __slots__ = ("_domain", "_hass")
__setitem__ = _readonly
__delitem__ = _readonly
@@ -1035,7 +1035,7 @@ class DomainStates:
class TemplateStateBase(State):
"""Class to represent a state object in a template."""
- __slots__ = ("_hass", "_collect", "_entity_id", "_state")
+ __slots__ = ("_collect", "_entity_id", "_hass", "_state")
_state: State
@@ -1735,7 +1735,7 @@ def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]:
return [entry.entity_id for entry in entries]
-def closest(hass, *args):
+def closest(hass: HomeAssistant, *args: Any) -> State | None:
"""Find closest entity.
Closest to home:
@@ -1775,21 +1775,24 @@ def closest(hass, *args):
)
return None
- latitude = point_state.attributes.get(ATTR_LATITUDE)
- longitude = point_state.attributes.get(ATTR_LONGITUDE)
+ latitude = point_state.attributes[ATTR_LATITUDE]
+ longitude = point_state.attributes[ATTR_LONGITUDE]
entities = args[1]
else:
- latitude = convert(args[0], float)
- longitude = convert(args[1], float)
+ latitude_arg = convert(args[0], float)
+ longitude_arg = convert(args[1], float)
- if latitude is None or longitude is None:
+ if latitude_arg is None or longitude_arg is None:
_LOGGER.warning(
"Closest:Received invalid coordinates: %s, %s", args[0], args[1]
)
return None
+ latitude = latitude_arg
+ longitude = longitude_arg
+
entities = args[2]
states = expand(hass, entities)
@@ -1798,20 +1801,20 @@ def closest(hass, *args):
return loc_helper.closest(latitude, longitude, states)
-def closest_filter(hass, *args):
+def closest_filter(hass: HomeAssistant, *args: Any) -> State | None:
"""Call closest as a filter. Need to reorder arguments."""
new_args = list(args[1:])
new_args.append(args[0])
return closest(hass, *new_args)
-def distance(hass, *args):
+def distance(hass: HomeAssistant, *args: Any) -> float | None:
"""Calculate distance.
Will calculate distance from home to a point or between points.
Points can be passed in using state objects or lat/lng coordinates.
"""
- locations = []
+ locations: list[tuple[float, float]] = []
to_process = list(args)
@@ -1831,10 +1834,10 @@ def distance(hass, *args):
return None
value_2 = to_process.pop(0)
- latitude = convert(value, float)
- longitude = convert(value_2, float)
+ latitude_to_process = convert(value, float)
+ longitude_to_process = convert(value_2, float)
- if latitude is None or longitude is None:
+ if latitude_to_process is None or longitude_to_process is None:
_LOGGER.warning(
"Distance:Unable to process latitude and longitude: %s, %s",
value,
@@ -1842,6 +1845,9 @@ def distance(hass, *args):
)
return None
+ latitude = latitude_to_process
+ longitude = longitude_to_process
+
else:
if not loc_helper.has_location(point_state):
_LOGGER.warning(
@@ -1849,8 +1855,8 @@ def distance(hass, *args):
)
return None
- latitude = point_state.attributes.get(ATTR_LATITUDE)
- longitude = point_state.attributes.get(ATTR_LONGITUDE)
+ latitude = point_state.attributes[ATTR_LATITUDE]
+ longitude = point_state.attributes[ATTR_LONGITUDE]
locations.append((latitude, longitude))
@@ -1858,7 +1864,7 @@ def distance(hass, *args):
return hass.config.distance(*locations[0])
return hass.config.units.length(
- loc_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS
+ location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS
)
@@ -1873,14 +1879,19 @@ def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> boo
"""Test if a state is a specific value."""
state_obj = _get_state(hass, entity_id)
return state_obj is not None and (
- state_obj.state == state or isinstance(state, list) and state_obj.state in state
+ state_obj.state == state
+ or (isinstance(state, list) and state_obj.state in state)
)
def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool:
"""Test if a state's attribute is a specific value."""
- attr = state_attr(hass, entity_id, name)
- return attr is not None and attr == value
+ if (state_obj := _get_state(hass, entity_id)) is not None:
+ attr = state_obj.attributes.get(name, _SENTINEL)
+ if attr is _SENTINEL:
+ return False
+ return bool(attr == value)
+ return False
def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any:
diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py
index 431a7a7d1f8..ef11028515a 100644
--- a/homeassistant/helpers/trace.py
+++ b/homeassistant/helpers/trace.py
@@ -10,7 +10,7 @@ from functools import wraps
from typing import Any
from homeassistant.core import ServiceResponse
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .typing import TemplateVarsType
@@ -23,11 +23,11 @@ class TraceElement:
"_child_run_id",
"_error",
"_last_variables",
- "path",
"_result",
- "reuse_by_child",
"_timestamp",
"_variables",
+ "path",
+ "reuse_by_child",
)
def __init__(self, variables: TemplateVarsType, path: str) -> None:
diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py
index 01c47aa8d0d..fdfefc9bff4 100644
--- a/homeassistant/helpers/translation.py
+++ b/homeassistant/helpers/translation.py
@@ -147,7 +147,7 @@ class _TranslationsCacheData:
class _TranslationCache:
"""Cache for flattened translations."""
- __slots__ = ("hass", "cache_data", "lock")
+ __slots__ = ("cache_data", "hass", "lock")
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the cache."""
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index 6cc4584935e..be765ff422d 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -6,16 +6,16 @@ from abc import abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Coroutine, Generator
from datetime import datetime, timedelta
+from functools import partial
import logging
from random import randint
from time import monotonic
-from typing import Any, Generic, Protocol
+from typing import Any, Generic, Protocol, TypeVar
import urllib.error
import aiohttp
-from propcache import cached_property
+from propcache.api import cached_property
import requests
-from typing_extensions import TypeVar
from homeassistant import config_entries
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
@@ -37,11 +37,6 @@ REQUEST_REFRESH_DEFAULT_COOLDOWN = 10
REQUEST_REFRESH_DEFAULT_IMMEDIATE = True
_DataT = TypeVar("_DataT", default=dict[str, Any])
-_DataUpdateCoordinatorT = TypeVar(
- "_DataUpdateCoordinatorT",
- bound="DataUpdateCoordinator[Any]",
- default="DataUpdateCoordinator[dict[str, Any]]",
-)
class UpdateFailed(HomeAssistantError):
@@ -109,7 +104,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6
)
- self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
+ self._listeners: dict[int, tuple[CALLBACK_TYPE, object | None]] = {}
+ self._last_listener_id: int = 0
self._unsub_refresh: CALLBACK_TYPE | None = None
self._unsub_shutdown: CALLBACK_TYPE | None = None
self._request_refresh_task: asyncio.TimerHandle | None = None
@@ -154,21 +150,26 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
) -> Callable[[], None]:
"""Listen for data updates."""
schedule_refresh = not self._listeners
-
- @callback
- def remove_listener() -> None:
- """Remove update listener."""
- self._listeners.pop(remove_listener)
- if not self._listeners:
- self._unschedule_refresh()
-
- self._listeners[remove_listener] = (update_callback, context)
+ self._last_listener_id += 1
+ self._listeners[self._last_listener_id] = (update_callback, context)
# This is the first listener, set up interval.
if schedule_refresh:
self._schedule_refresh()
- return remove_listener
+ return partial(self.__async_remove_listener_internal, self._last_listener_id)
+
+ @callback
+ def __async_remove_listener_internal(self, listener_id: int) -> None:
+ """Remove a listener.
+
+ This is an internal function that is not to be overridden
+ in subclasses as it may change in the future.
+ """
+ self._listeners.pop(listener_id)
+ if not self._listeners:
+ self._unschedule_refresh()
+ self._debounced_refresh.async_cancel()
@callback
def async_update_listeners(self) -> None:
@@ -365,7 +366,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
self._async_unsub_refresh()
self._debounced_refresh.async_cancel()
- if self._shutdown_requested or scheduled and self.hass.is_stopping:
+ if self._shutdown_requested or (scheduled and self.hass.is_stopping):
return
if log_timing := self.logger.isEnabledFor(logging.DEBUG):
@@ -459,7 +460,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
self.logger.debug(
"Finished fetching %s data in %.3f seconds (success: %s)",
self.name,
- monotonic() - start, # pylint: disable=possibly-used-before-assignment
+ monotonic() - start,
self.last_update_success,
)
if not auth_failed and self._listeners and not self.hass.is_stopping:
@@ -565,7 +566,11 @@ class BaseCoordinatorEntity[
"""
-class CoordinatorEntity(BaseCoordinatorEntity[_DataUpdateCoordinatorT]):
+class CoordinatorEntity[
+ _DataUpdateCoordinatorT: DataUpdateCoordinator[Any] = DataUpdateCoordinator[
+ dict[str, Any]
+ ]
+](BaseCoordinatorEntity[_DataUpdateCoordinatorT]):
"""A class for entities using DataUpdateCoordinator."""
def __init__(
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 93dc7677bba..92b588dbe15 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -25,7 +25,7 @@ from awesomeversion import (
AwesomeVersionException,
AwesomeVersionStrategy,
)
-from propcache import cached_property
+from propcache.api import cached_property
import voluptuous as vol
from . import generated
@@ -1765,8 +1765,7 @@ def async_suggest_report_issue(
if not integration_domain:
return "report it to the custom integration author"
return (
- f"report it to the author of the '{integration_domain}' "
- "custom integration"
+ f"report it to the author of the '{integration_domain}' custom integration"
)
return f"create a bug report at {issue_tracker}"
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 061ff2a0ef7..2c3513589b2 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -1,43 +1,45 @@
# Automatically generated by gen_requirements_all.py, do not edit
-aiodhcpwatcher==1.0.2
+aiodhcpwatcher==1.0.3
aiodiscover==2.1.0
aiodns==3.2.0
-aiohasupervisor==0.2.2b5
+aiohasupervisor==0.3.0
+aiohttp-asyncmdnsresolver==0.0.3
aiohttp-fast-zlib==0.2.0
aiohttp==3.11.11
aiohttp_cors==0.7.0
+aiousbwatcher==1.1.1
aiozoneinfo==0.2.1
astral==2.2
async-interrupt==1.2.0
-async-upnp-client==0.42.0
+async-upnp-client==0.43.0
atomicwrites-homeassistant==1.4.1
attrs==24.2.0
audioop-lts==0.2.1;python_version>='3.13'
av==13.1.0
awesomeversion==24.6.0
bcrypt==4.2.0
-bleak-retry-connector==3.6.0
+bleak-retry-connector==3.8.1
bleak==0.22.3
-bluetooth-adapters==0.20.2
+bluetooth-adapters==0.21.4
bluetooth-auto-recovery==1.4.2
-bluetooth-data-tools==1.20.0
+bluetooth-data-tools==1.23.4
cached-ipaddress==0.8.0
certifi>=2021.5.30
ciso8601==2.3.2
cronsim==2.6
cryptography==44.0.0
-dbus-fast==2.24.3
-fnv-hash-fast==1.0.2
+dbus-fast==2.33.0
+fnv-hash-fast==1.2.2
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
-habluetooth==3.7.0
-hass-nabucasa==0.87.0
-hassil==2.1.0
+habluetooth==3.21.1
+hass-nabucasa==0.88.1
+hassil==2.2.3
home-assistant-bluetooth==1.13.0
-home-assistant-frontend==20250109.2
-home-assistant-intents==2025.1.1
-httpx==0.27.2
+home-assistant-frontend==20250205.0
+home-assistant-intents==2025.2.5
+httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.5
lru-dict==1.3.0
@@ -45,7 +47,7 @@ mutagen==1.47.0
orjson==3.10.12
packaging>=23.1
paho-mqtt==1.6.1
-Pillow==11.0.0
+Pillow==11.1.0
propcache==0.2.1
psutil-home-assistant==0.0.1
PyJWT==2.10.1
@@ -56,23 +58,22 @@ pyserial==3.5
pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.7.5
-pyudev==0.24.1
PyYAML==6.0.2
requests==2.32.3
-securetar==2024.11.0
-SQLAlchemy==2.0.36
+securetar==2025.1.4
+SQLAlchemy==2.0.37
standard-aifc==3.13.0;python_version>='3.13'
standard-telnetlib==3.13.0;python_version>='3.13'
typing-extensions>=4.12.2,<5.0
-ulid-transform==1.0.2
+ulid-transform==1.2.0
urllib3>=1.26.5,<2
-uv==0.5.8
-voluptuous-openapi==0.0.5
+uv==0.5.21
+voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.18.3
-zeroconf==0.136.2
+zeroconf==0.143.0
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
@@ -107,16 +108,16 @@ uuid==1000000000.0.0
# 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
# requirements so we can directly link HA versions to these library versions.
-anyio==4.7.0
+anyio==4.8.0
h11==0.14.0
-httpcore==1.0.5
+httpcore==1.0.7
# Ensure we have a hyperframe version that works in Python 3.10
# 5.2.0 fixed a collections abc deprecation
hyperframe>=5.2.0
# Ensure we run compatible with musllinux build env
-numpy==2.2.0
+numpy==2.2.2
pandas~=2.2.3
# Constrain multidict to avoid typing issues
@@ -127,7 +128,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
-pydantic==2.10.4
+pydantic==2.10.6
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
index b769d385a4f..c16269a2a8b 100644
--- a/homeassistant/scripts/benchmark/__init__.py
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -58,7 +58,7 @@ def benchmark[_CallableT: Callable](func: _CallableT) -> _CallableT:
@benchmark
-async def fire_events(hass):
+async def fire_events(hass: core.HomeAssistant) -> float:
"""Fire a million events."""
count = 0
event_name = "benchmark_event"
@@ -85,7 +85,7 @@ async def fire_events(hass):
@benchmark
-async def fire_events_with_filter(hass):
+async def fire_events_with_filter(hass: core.HomeAssistant) -> float:
"""Fire a million events with a filter that rejects them."""
count = 0
event_name = "benchmark_event"
@@ -117,7 +117,7 @@ async def fire_events_with_filter(hass):
@benchmark
-async def state_changed_helper(hass):
+async def state_changed_helper(hass: core.HomeAssistant) -> float:
"""Run a million events through state changed helper with 1000 entities."""
count = 0
entity_id = "light.kitchen"
@@ -141,7 +141,7 @@ async def state_changed_helper(hass):
}
for _ in range(10**6):
- hass.bus.async_fire(EVENT_STATE_CHANGED, event_data)
+ hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc]
start = timer()
@@ -151,7 +151,7 @@ async def state_changed_helper(hass):
@benchmark
-async def state_changed_event_helper(hass):
+async def state_changed_event_helper(hass: core.HomeAssistant) -> float:
"""Run a million events through state changed event helper with 1000 entities."""
count = 0
entity_id = "light.kitchen"
@@ -174,7 +174,7 @@ async def state_changed_event_helper(hass):
}
for _ in range(events_to_fire):
- hass.bus.async_fire(EVENT_STATE_CHANGED, event_data)
+ hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc]
start = timer()
@@ -186,7 +186,7 @@ async def state_changed_event_helper(hass):
@benchmark
-async def state_changed_event_filter_helper(hass):
+async def state_changed_event_filter_helper(hass: core.HomeAssistant) -> float:
"""Run a million events through state changed event helper.
With 1000 entities that all get filtered.
@@ -212,7 +212,7 @@ async def state_changed_event_filter_helper(hass):
}
for _ in range(events_to_fire):
- hass.bus.async_fire(EVENT_STATE_CHANGED, event_data)
+ hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc]
start = timer()
@@ -224,7 +224,7 @@ async def state_changed_event_filter_helper(hass):
@benchmark
-async def filtering_entity_id(hass):
+async def filtering_entity_id(hass: core.HomeAssistant) -> float:
"""Run a 100k state changes through entity filter."""
config = {
"include": {
@@ -289,7 +289,7 @@ async def filtering_entity_id(hass):
@benchmark
-async def valid_entity_id(hass):
+async def valid_entity_id(hass: core.HomeAssistant) -> float:
"""Run valid entity ID a million times."""
start = timer()
for _ in range(10**6):
@@ -298,7 +298,7 @@ async def valid_entity_id(hass):
@benchmark
-async def json_serialize_states(hass):
+async def json_serialize_states(hass: core.HomeAssistant) -> float:
"""Serialize million states with websocket default encoder."""
states = [
core.State("light.kitchen", "on", {"friendly_name": "Kitchen Lights"})
diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py
index 568e8c84a30..a24568e9a6f 100644
--- a/homeassistant/scripts/check_config.py
+++ b/homeassistant/scripts/check_config.py
@@ -23,8 +23,7 @@ from homeassistant.helpers import (
issue_registry as ir,
)
from homeassistant.helpers.check_config import async_check_ha_config_file
-from homeassistant.util.yaml import Secrets
-import homeassistant.util.yaml.loader as yaml_loader
+from homeassistant.util.yaml import Secrets, loader as yaml_loader
# mypy: allow-untyped-calls, allow-untyped-defs
diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py
index e1ae7bc9142..1d568ec68b0 100644
--- a/homeassistant/scripts/ensure_config.py
+++ b/homeassistant/scripts/ensure_config.py
@@ -4,7 +4,7 @@ import argparse
import asyncio
import os
-import homeassistant.config as config_util
+from homeassistant import config as config_util
from homeassistant.core import HomeAssistant
# mypy: allow-untyped-calls, allow-untyped-defs
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index 331389da7c6..1fa93a80cd5 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -425,8 +425,8 @@ async def _async_setup_component(
)
return False
# pylint: disable-next=broad-except
- except (asyncio.CancelledError, SystemExit, Exception):
- _LOGGER.exception("Error during setup of component %s", domain)
+ except (asyncio.CancelledError, SystemExit, Exception) as exc:
+ _LOGGER.exception("Error during setup of component %s: %s", domain, exc) # noqa: TRY401
async_notify_setup_error(hass, domain, integration.documentation)
return False
finally:
diff --git a/homeassistant/util/event_type.py b/homeassistant/util/event_type.py
index 509a35d33ae..e0083057272 100644
--- a/homeassistant/util/event_type.py
+++ b/homeassistant/util/event_type.py
@@ -6,14 +6,10 @@ Custom for type checking. See stub file.
from __future__ import annotations
from collections.abc import Mapping
-from typing import Any, Generic
-
-from typing_extensions import TypeVar
-
-_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any])
+from typing import Any
-class EventType(str, Generic[_DataT]):
+class EventType[_DataT: Mapping[str, Any] = Mapping[str, Any]](str):
"""Custom type for Event.event_type.
At runtime this is a generic subclass of str.
diff --git a/homeassistant/util/event_type.pyi b/homeassistant/util/event_type.pyi
index 4285e54e8c9..f9cb140440f 100644
--- a/homeassistant/util/event_type.pyi
+++ b/homeassistant/util/event_type.pyi
@@ -2,15 +2,15 @@
# ruff: noqa: PYI021 # Allow docstrings
from collections.abc import Mapping
-from typing import Any, Generic
-
-from typing_extensions import TypeVar
+from typing import Any, Generic, TypeVar
__all__ = [
"EventType",
]
-_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any])
+_DataT = TypeVar( # needs to be invariant
+ "_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]
+)
class EventType(Generic[_DataT]):
"""Custom type for Event.event_type. At runtime delegated to str.
diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py
index 968567ae0c9..a935d44d585 100644
--- a/homeassistant/util/json.py
+++ b/homeassistant/util/json.py
@@ -46,7 +46,7 @@ def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayT
"""Parse JSON data and ensure result is a list."""
value: JsonValueType = json_loads(obj)
# Avoid isinstance overhead as we are not interested in list subclasses
- if type(value) is list: # noqa: E721
+ if type(value) is list:
return value
raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}")
@@ -55,7 +55,7 @@ def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjec
"""Parse JSON data and ensure result is a dictionary."""
value: JsonValueType = json_loads(obj)
# Avoid isinstance overhead as we are not interested in dict subclasses
- if type(value) is dict: # noqa: E721
+ if type(value) is dict:
return value
raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}")
@@ -95,7 +95,7 @@ def load_json_array(
default = []
value: JsonValueType = load_json(filename, default=default)
# Avoid isinstance overhead as we are not interested in list subclasses
- if type(value) is list: # noqa: E721
+ if type(value) is list:
return value
_LOGGER.exception(
"Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename
@@ -115,7 +115,7 @@ def load_json_object(
default = {}
value: JsonValueType = load_json(filename, default=default)
# Avoid isinstance overhead as we are not interested in dict subclasses
- if type(value) is dict: # noqa: E721
+ if type(value) is dict:
return value
_LOGGER.exception(
"Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename
diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py
index d7593013046..bebd399a5cd 100644
--- a/homeassistant/util/loop.py
+++ b/homeassistant/util/loop.py
@@ -93,8 +93,9 @@ def raise_for_blocking_call(
return
if found_frame is None:
- raise RuntimeError( # noqa: TRY200
- f"Caught blocking call to {func.__name__} with args {mapped_args.get("args")} "
+ raise RuntimeError( # noqa: B904
+ f"Caught blocking call to {func.__name__} "
+ f"with args {mapped_args.get('args')} "
f"in {offender_filename}, line {offender_lineno}: {offender_line} "
"inside the event loop; "
"This is causing stability issues. "
diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py
index 08a2c2a3967..70d7dc80505 100644
--- a/homeassistant/util/network.py
+++ b/homeassistant/util/network.py
@@ -98,8 +98,7 @@ def is_host_valid(host: str) -> bool:
return False
if re.match(r"^[0-9\.]+$", host): # reject invalid IPv4
return False
- if host.endswith("."): # dot at the end is correct
- host = host[:-1]
+ host = host.removesuffix(".")
allowed = re.compile(r"(?!-)[A-Z\d\-]{1,63}(? None:
"""Check for improper 'from ._ import _' invocations."""
- if (
- node.level <= 1
- or not current_package.startswith("homeassistant.components.")
+ if node.level <= 1 or (
+ not current_package.startswith("homeassistant.components.")
and not current_package.startswith("tests.components.")
):
return
diff --git a/pyproject.toml b/pyproject.toml
index fad27cfd7f5..d7c0761887f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2025.1.4"
+version = "2025.2.0"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -18,20 +18,20 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
- "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Home Automation",
]
-requires-python = ">=3.12.0"
+requires-python = ">=3.13.0"
dependencies = [
"aiodns==3.2.0",
# Integrations may depend on hassio integration without listing it to
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
- "aiohasupervisor==0.2.2b5",
+ "aiohasupervisor==0.3.0",
"aiohttp==3.11.11",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.2.0",
+ "aiohttp-asyncmdnsresolver==0.0.3",
"aiozoneinfo==0.2.1",
"astral==2.2",
"async-interrupt==1.2.0",
@@ -43,13 +43,13 @@ dependencies = [
"certifi>=2021.5.30",
"ciso8601==2.3.2",
"cronsim==2.6",
- "fnv-hash-fast==1.0.2",
+ "fnv-hash-fast==1.2.2",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
- "hass-nabucasa==0.87.0",
+ "hass-nabucasa==0.88.1",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
- "httpx==0.27.2",
+ "httpx==0.28.1",
"home-assistant-bluetooth==1.13.0",
"ifaddr==0.2.0",
"Jinja2==3.1.5",
@@ -57,7 +57,7 @@ dependencies = [
"PyJWT==2.10.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==44.0.0",
- "Pillow==11.0.0",
+ "Pillow==11.1.0",
"propcache==0.2.1",
"pyOpenSSL==24.3.0",
"orjson==3.10.12",
@@ -66,22 +66,23 @@ dependencies = [
"python-slugify==8.0.4",
"PyYAML==6.0.2",
"requests==2.32.3",
- "securetar==2024.11.0",
- "SQLAlchemy==2.0.36",
+ "securetar==2025.1.4",
+ "SQLAlchemy==2.0.37",
"standard-aifc==3.13.0;python_version>='3.13'",
"standard-telnetlib==3.13.0;python_version>='3.13'",
"typing-extensions>=4.12.2,<5.0",
- "ulid-transform==1.0.2",
+ "ulid-transform==1.2.0",
# 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
"urllib3>=1.26.5,<2",
- "uv==0.5.8",
+ "uv==0.5.21",
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
- "voluptuous-openapi==0.0.5",
+ "voluptuous-openapi==0.0.6",
"yarl==1.18.3",
"webrtc-models==0.3.0",
+ "zeroconf==0.143.0"
]
[project.urls]
@@ -102,7 +103,7 @@ include-package-data = true
include = ["homeassistant*"]
[tool.pylint.MAIN]
-py-version = "3.12"
+py-version = "3.13"
# Use a conservative default here; 2 should speed up most setups and not hurt
# any too bad. Override on command line as appropriate.
jobs = 2
@@ -699,7 +700,7 @@ exclude_lines = [
]
[tool.ruff]
-required-version = ">=0.8.0"
+required-version = ">=0.9.1"
[tool.ruff.lint]
select = [
@@ -718,8 +719,10 @@ select = [
"B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name}
+ "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
+ "B035", # Dictionary comprehension uses static key
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"BLE",
@@ -753,12 +756,27 @@ select = [
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
+ "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
+ "RUF008", # Do not use mutable default values for dataclass attributes
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
+ "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
"RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access
- # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up
+ "RUF020", # {never_like} | T is equivalent to T
+ "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
+ "RUF022", # Sort __all__
+ "RUF023", # Sort __slots__
+ "RUF024", # Do not pass mutable objects as values to dict.fromkeys
+ "RUF026", # default_factory is a positional-only argument to defaultdict
+ "RUF030", # print() call in assert statement is likely unintentional
+ "RUF032", # Decimal() called with float literal argument
+ "RUF033", # __post_init__ method with argument defaults
+ "RUF034", # Useless if-else condition
+ "RUF100", # Unused `noqa` directive
+ "RUF101", # noqa directives that use redirected rule codes
+ "RUF200", # Failed to parse pyproject.toml: {message}
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
@@ -837,7 +855,6 @@ ignore = [
"Q",
"COM812",
"COM819",
- "ISC001",
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605"
@@ -895,7 +912,15 @@ voluptuous = "vol"
"homeassistant.helpers.floor_registry" = "fr"
"homeassistant.helpers.issue_registry" = "ir"
"homeassistant.helpers.label_registry" = "lr"
+"homeassistant.util.color" = "color_util"
"homeassistant.util.dt" = "dt_util"
+"homeassistant.util.json" = "json_util"
+"homeassistant.util.location" = "location_util"
+"homeassistant.util.logging" = "logging_util"
+"homeassistant.util.network" = "network_util"
+"homeassistant.util.ulid" = "ulid_util"
+"homeassistant.util.uuid" = "uuid_util"
+"homeassistant.util.yaml" = "yaml_util"
[tool.ruff.lint.flake8-pytest-style]
fixture-parentheses = false
@@ -904,6 +929,7 @@ mark-parentheses = false
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"async_timeout".msg = "use asyncio.timeout instead"
"pytz".msg = "use zoneinfo instead"
+"tests".msg = "You should not import tests"
[tool.ruff.lint.isort]
force-sort-within-sections = true
@@ -933,4 +959,4 @@ split-on-trailing-comma = false
max-complexity = 25
[tool.ruff.lint.pydocstyle]
-property-decorators = ["propcache.cached_property"]
+property-decorators = ["propcache.api.cached_property"]
diff --git a/requirements.txt b/requirements.txt
index 0d898edcd4b..0f5ac0ba7d6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,10 +4,11 @@
# Home Assistant Core
aiodns==3.2.0
-aiohasupervisor==0.2.2b5
+aiohasupervisor==0.3.0
aiohttp==3.11.11
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.2.0
+aiohttp-asyncmdnsresolver==0.0.3
aiozoneinfo==0.2.1
astral==2.2
async-interrupt==1.2.0
@@ -19,16 +20,16 @@ bcrypt==4.2.0
certifi>=2021.5.30
ciso8601==2.3.2
cronsim==2.6
-fnv-hash-fast==1.0.2
-hass-nabucasa==0.87.0
-httpx==0.27.2
+fnv-hash-fast==1.2.2
+hass-nabucasa==0.88.1
+httpx==0.28.1
home-assistant-bluetooth==1.13.0
ifaddr==0.2.0
Jinja2==3.1.5
lru-dict==1.3.0
PyJWT==2.10.1
cryptography==44.0.0
-Pillow==11.0.0
+Pillow==11.1.0
propcache==0.2.1
pyOpenSSL==24.3.0
orjson==3.10.12
@@ -37,16 +38,17 @@ psutil-home-assistant==0.0.1
python-slugify==8.0.4
PyYAML==6.0.2
requests==2.32.3
-securetar==2024.11.0
-SQLAlchemy==2.0.36
+securetar==2025.1.4
+SQLAlchemy==2.0.37
standard-aifc==3.13.0;python_version>='3.13'
standard-telnetlib==3.13.0;python_version>='3.13'
typing-extensions>=4.12.2,<5.0
-ulid-transform==1.0.2
+ulid-transform==1.2.0
urllib3>=1.26.5,<2
-uv==0.5.8
+uv==0.5.21
voluptuous==0.15.2
voluptuous-serialize==2.6.0
-voluptuous-openapi==0.0.5
+voluptuous-openapi==0.0.6
yarl==1.18.3
webrtc-models==0.3.0
+zeroconf==0.143.0
diff --git a/requirements_all.txt b/requirements_all.txt
index 9fd6a3af6a9..b1028c3efad 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -7,7 +7,7 @@
AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
-AIOSomecomfort==0.0.28
+AIOSomecomfort==0.0.32
# homeassistant.components.adax
Adax-local==0.1.5
@@ -33,7 +33,7 @@ Mastodon.py==1.8.1
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
-Pillow==11.0.0
+Pillow==11.1.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -100,7 +100,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.39.1
+PyViCare==2.41.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -109,14 +109,14 @@ PyXiaomiGateway==0.14.3
RachioPy==1.1.0
# homeassistant.components.python_script
-RestrictedPython==7.4
+RestrictedPython==8.0
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.36
+SQLAlchemy==2.0.37
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -128,7 +128,7 @@ TravisPy==0.3.5
TwitterAPI==2.7.12
# homeassistant.components.onvif
-WSDiscovery==2.0.0
+WSDiscovery==2.1.2
# homeassistant.components.accuweather
accuweather==4.0.0
@@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3
aio-georss-gdacs==0.10
# homeassistant.components.acaia
-aioacaia==0.1.13
+aioacaia==0.1.14
# homeassistant.components.airq
aioairq==0.4.3
@@ -201,7 +201,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2025.1.0
+aioautomower==2025.1.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -216,7 +216,7 @@ aiobotocore==2.13.1
aiocomelit==0.10.1
# homeassistant.components.dhcp
-aiodhcpwatcher==1.0.2
+aiodhcpwatcher==1.0.3
# homeassistant.components.dhcp
aiodiscover==2.1.0
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==28.0.0
+aioesphomeapi==29.0.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -258,19 +258,22 @@ aiogithubapi==24.6.0
aioguardian==2022.07.0
# homeassistant.components.harmony
-aioharmony==0.2.10
+aioharmony==0.4.1
# homeassistant.components.hassio
-aiohasupervisor==0.2.2b5
+aiohasupervisor==0.3.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.7
+# homeassistant.components.mcp_server
+aiohttp_sse==2.2.0
+
# homeassistant.components.hue
aiohue==4.7.3
# homeassistant.components.imap
-aioimaplib==1.1.0
+aioimaplib==2.0.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
@@ -282,7 +285,7 @@ aiokef==0.2.16
aiolifx-effects==0.3.2
# homeassistant.components.lifx
-aiolifx-themes==0.6.0
+aiolifx-themes==0.6.4
# homeassistant.components.lifx
aiolifx==1.1.2
@@ -356,7 +359,7 @@ aioridwell==2024.01.0
aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.1.1
+aiorussound==4.4.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -365,7 +368,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.2.0
+aioshelly==12.3.2
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -383,7 +386,7 @@ aiosteamist==1.0.0
aiostreammagic==2.10.0
# homeassistant.components.switcher_kis
-aioswitcher==5.1.0
+aioswitcher==6.0.0
# homeassistant.components.syncthing
aiosyncthing==0.5.1
@@ -400,6 +403,9 @@ aiotractive==0.6.0
# homeassistant.components.unifi
aiounifi==81
+# homeassistant.components.usb
+aiousbwatcher==1.1.1
+
# homeassistant.components.vlc_telnet
aiovlc==0.5.1
@@ -413,7 +419,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webostv
-aiowebostv==0.4.2
+aiowebostv==0.6.1
# homeassistant.components.withings
aiowithings==3.1.5
@@ -464,7 +470,10 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
-anthropic==0.31.2
+anthropic==0.44.0
+
+# homeassistant.components.mcp_server
+anyio==4.8.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -482,7 +491,7 @@ apsystems-ez1==2.4.0
aqualogic==2.6
# homeassistant.components.aranet
-aranet4==2.4.0
+aranet4==2.5.1
# homeassistant.components.arcam_fmj
arcam-fmj==1.5.2
@@ -499,13 +508,13 @@ asmog==0.0.6
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.42.0
+async-upnp-client==0.43.0
# homeassistant.components.arve
asyncarve==0.1.1
# homeassistant.components.keyboard_remote
-asyncinotify==4.0.2
+asyncinotify==4.2.0
# homeassistant.components.supla
asyncpysupla==0.0.5
@@ -542,7 +551,7 @@ av==13.1.0
axis==64
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.4
+ayla-iot-unofficial==1.4.5
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -585,10 +594,10 @@ bizkaibus==0.1.1
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==2.0.0
+bleak-esphome==2.7.0
# homeassistant.components.bluetooth
-bleak-retry-connector==3.6.0
+bleak-retry-connector==3.8.1
# homeassistant.components.bluetooth
bleak==0.22.3
@@ -613,7 +622,7 @@ bluemaestro-ble==0.2.3
# bluepy==1.3.0
# homeassistant.components.bluetooth
-bluetooth-adapters==0.20.2
+bluetooth-adapters==0.21.4
# homeassistant.components.bluetooth
bluetooth-auto-recovery==1.4.2
@@ -622,7 +631,7 @@ bluetooth-auto-recovery==1.4.2
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.20.0
+bluetooth-data-tools==1.23.4
# homeassistant.components.bond
bond-async==0.2.1
@@ -638,7 +647,7 @@ boto3==1.34.131
botocore==1.34.131
# homeassistant.components.bring
-bring-api==0.9.1
+bring-api==1.0.0
# homeassistant.components.broadlink
broadlink==0.19.0
@@ -656,7 +665,7 @@ brunt==1.2.0
bt-proximity==0.2.1
# homeassistant.components.bthome
-bthome-ble==3.9.1
+bthome-ble==3.12.3
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -726,7 +735,7 @@ datadog==0.15.0
datapoint==0.9.9
# homeassistant.components.bluetooth
-dbus-fast==2.24.3
+dbus-fast==2.33.0
# homeassistant.components.debugpy
debugpy==1.8.11
@@ -738,7 +747,7 @@ debugpy==1.8.11
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==11.0.0
+deebot-client==12.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -755,7 +764,7 @@ demetriek==1.2.0
denonavr==1.0.1
# homeassistant.components.devialet
-devialet==1.4.5
+devialet==1.5.7
# homeassistant.components.devolo_home_control
devolo-home-control-api==0.18.3
@@ -809,7 +818,7 @@ ebusdpy==0.0.17
ecoaliface==0.4.0
# homeassistant.components.eheimdigital
-eheimdigital==1.0.3
+eheimdigital==1.0.5
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
@@ -924,11 +933,11 @@ flexit_bacnet==2.2.1
flipr-api==1.6.1
# homeassistant.components.flux_led
-flux-led==1.1.0
+flux-led==1.1.3
# homeassistant.components.homekit
# homeassistant.components.recorder
-fnv-hash-fast==1.0.2
+fnv-hash-fast==1.2.2
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -953,7 +962,7 @@ fritzconnection[qr]==1.14.0
fyta_cli==0.7.0
# homeassistant.components.google_translate
-gTTS==2.2.4
+gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
gardena-bluetooth==1.5.0
@@ -962,7 +971,7 @@ gardena-bluetooth==1.5.0
gassist-text==0.0.11
# homeassistant.components.google
-gcal-sync==6.2.0
+gcal-sync==7.0.0
# homeassistant.components.geniushub
geniushub-client==0.7.1
@@ -987,7 +996,7 @@ georss-qld-bushfire-alert-client==0.8
# homeassistant.components.nmap_tracker
# homeassistant.components.samsungtv
# homeassistant.components.upnp
-getmac==0.9.4
+getmac==0.9.5
# homeassistant.components.gios
gios==5.0.0
@@ -1024,7 +1033,7 @@ google-cloud-texttospeech==2.17.2
google-generativeai==0.8.2
# homeassistant.components.nest
-google-nest-sdm==6.1.5
+google-nest-sdm==7.1.1
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -1040,7 +1049,7 @@ goslide-api==0.7.0
gotailwind==0.3.0
# homeassistant.components.govee_ble
-govee-ble==0.40.0
+govee-ble==0.42.0
# homeassistant.components.govee_light_local
govee-local-api==1.5.3
@@ -1073,7 +1082,7 @@ gspread==5.5.0
gstreamer-player==1.1.2
# homeassistant.components.profiler
-guppy3==3.1.4.post1;python_version<'3.13'
+guppy3==3.1.5
# homeassistant.components.iaqualink
h2==4.1.0
@@ -1088,19 +1097,19 @@ ha-iotawattpy==0.1.2
ha-philipsjs==3.2.2
# homeassistant.components.habitica
-habitipy==0.3.3
+habiticalib==0.3.4
# homeassistant.components.bluetooth
-habluetooth==3.7.0
+habluetooth==3.21.1
# homeassistant.components.cloud
-hass-nabucasa==0.87.0
+hass-nabucasa==0.88.1
# homeassistant.components.splunk
hass-splunk==0.1.1
# homeassistant.components.conversation
-hassil==2.1.0
+hassil==2.2.3
# homeassistant.components.jewish_calendar
hdate==0.11.1
@@ -1134,16 +1143,16 @@ hole==0.8.0
holidays==0.65
# homeassistant.components.frontend
-home-assistant-frontend==20250109.2
+home-assistant-frontend==20250205.0
# homeassistant.components.conversation
-home-assistant-intents==2025.1.1
+home-assistant-intents==2025.2.5
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
-homematicip==1.1.5
+homematicip==1.1.7
# homeassistant.components.horizon
horimote==0.4.1
@@ -1175,7 +1184,7 @@ ibmiotf==0.3.4
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
-ical==8.2.0
+ical==8.3.0
# homeassistant.components.caldav
icalendar==6.1.0
@@ -1192,14 +1201,17 @@ ifaddr==0.2.0
# homeassistant.components.iglo
iglo==1.2.7
+# homeassistant.components.igloohome
+igloohome-api==0.1.0
+
# homeassistant.components.ihc
ihcsdk==2.8.5
# homeassistant.components.imgw_pib
-imgw_pib==1.0.7
+imgw_pib==1.0.9
# homeassistant.components.incomfort
-incomfort-client==0.6.4
+incomfort-client==0.6.7
# homeassistant.components.influxdb
influxdb-client==1.24.0
@@ -1235,7 +1247,7 @@ israel-rail-api==0.1.2
jaraco.abode==6.2.1
# homeassistant.components.jellyfin
-jellyfin-apiclient-python==1.9.2
+jellyfin-apiclient-python==1.10.0
# homeassistant.components.command_line
# homeassistant.components.rest
@@ -1260,7 +1272,7 @@ kiwiki-client==0.1.1
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2025.1.18.164225
+knx-frontend==2025.1.30.194235
# homeassistant.components.konnected
konnected==1.2.0
@@ -1269,7 +1281,7 @@ konnected==1.2.0
krakenex==2.2.2
# homeassistant.components.lacrosse_view
-lacrosse-view==1.0.3
+lacrosse-view==1.0.4
# homeassistant.components.eufy
lakeside==0.13
@@ -1278,7 +1290,7 @@ lakeside==0.13
laundrify-aio==1.2.2
# homeassistant.components.lcn
-lcn-frontend==0.2.2
+lcn-frontend==0.2.3
# homeassistant.components.ld2410_ble
ld2410-ble==0.1.1
@@ -1287,11 +1299,14 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
-led-ble==1.1.1
+led-ble==1.1.6
# homeassistant.components.lektrico
lektricowifi==0.0.43
+# homeassistant.components.letpot
+letpot==0.3.0
+
# homeassistant.components.foscam
libpyfoscam==1.2.2
@@ -1352,6 +1367,10 @@ maxcube-api==0.4.3
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
+# homeassistant.components.mcp
+# homeassistant.components.mcp_server
+mcp==1.1.2
+
# homeassistant.components.minecraft_server
mcstatus==11.1.1
@@ -1386,7 +1405,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
-millheater==0.12.2
+millheater==0.12.3
# homeassistant.components.minio
minio==7.1.12
@@ -1431,7 +1450,7 @@ mutesync==0.0.1
mypermobil==0.1.8
# homeassistant.components.myuplink
-myuplink==0.6.0
+myuplink==0.7.0
# homeassistant.components.nad
nad-receiver==0.3.0
@@ -1467,13 +1486,13 @@ nextcord==2.6.0
nextdns==4.0.0
# homeassistant.components.niko_home_control
-nhc==0.3.4
+nhc==0.3.9
# homeassistant.components.nibe_heatpump
nibe==2.14.0
# homeassistant.components.nice_go
-nice-go==1.0.0
+nice-go==1.0.1
# homeassistant.components.nilu
niluclient==0.1.2
@@ -1504,7 +1523,7 @@ numato-gpio==0.13.0
# homeassistant.components.stream
# homeassistant.components.tensorflow
# homeassistant.components.trend
-numpy==2.2.0
+numpy==2.2.2
# homeassistant.components.nyt_games
nyt_games==0.4.4
@@ -1525,10 +1544,10 @@ odp-amsterdam==6.0.2
oemthermostat==1.1.1
# homeassistant.components.ohme
-ohme==1.2.0
+ohme==1.2.8
# homeassistant.components.ollama
-ollama==0.4.5
+ollama==0.4.7
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -1536,8 +1555,11 @@ omnilogic==0.4.5
# homeassistant.components.ondilo_ico
ondilo==0.5.0
+# homeassistant.components.onedrive
+onedrive-personal-sdk==0.0.8
+
# homeassistant.components.onvif
-onvif-zeep-async==3.2.3
+onvif-zeep-async==3.2.5
# homeassistant.components.opengarage
open-garage==0.2.0
@@ -1546,7 +1568,7 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.openai_conversation
-openai==1.35.7
+openai==1.59.9
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1570,7 +1592,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
-opower==0.8.7
+opower==0.8.9
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1606,7 +1628,7 @@ pdunehd==1.3.2
peblar==0.4.0
# homeassistant.components.peco
-peco==0.0.30
+peco==0.1.2
# homeassistant.components.pencom
pencompy==0.0.3
@@ -1617,7 +1639,7 @@ pescea==1.0.12
# homeassistant.components.aruba
# homeassistant.components.cisco_ios
# homeassistant.components.pandora
-pexpect==4.6.0
+pexpect==4.9.0
# homeassistant.components.modem_callerid
phone-modem==0.1.1
@@ -1673,7 +1695,7 @@ proxmoxer==2.0.1
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
-psutil==6.1.0
+psutil==6.1.1
# homeassistant.components.pulseaudio_loopback
pulsectl==23.5.2
@@ -1724,7 +1746,7 @@ py-schluter==0.1.7
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.5.3
+py-synologydsm-api==2.6.0
# homeassistant.components.atome
pyAtome==0.1.1
@@ -1747,6 +1769,9 @@ pyEmby==1.10
# homeassistant.components.hikvision
pyHik==0.3.2
+# homeassistant.components.homee
+pyHomee==1.2.5
+
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1872,7 +1897,7 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.12.0
+pydrawise==2025.1.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1905,7 +1930,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.23.0
+pyenphase==1.23.1
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -1944,7 +1969,7 @@ pyforked-daapd==0.1.14
pyfreedompro==1.1.0
# homeassistant.components.fritzbox
-pyfritzhome==0.6.12
+pyfritzhome==0.6.14
# homeassistant.components.ifttt
pyfttt==0.3
@@ -1962,7 +1987,7 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
-pyheos==0.7.2
+pyheos==1.0.1
# homeassistant.components.hive
pyhive-integration==1.0.1
@@ -1986,7 +2011,7 @@ pyinsteon==1.6.3
pyintesishome==1.8.0
# homeassistant.components.ipma
-pyipma==3.0.7
+pyipma==3.0.8
# homeassistant.components.ipp
pyipp==0.17.0
@@ -1998,7 +2023,7 @@ pyiqvia==2022.04.0
pyirishrail==0.0.2
# homeassistant.components.iskra
-pyiskra==0.1.14
+pyiskra==0.1.15
# homeassistant.components.iss
pyiss==1.0.1
@@ -2061,10 +2086,10 @@ pylibrespot-java==0.1.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
-pylitterbot==2023.5.0
+pylitterbot==2024.0.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.22.0
+pylutron-caseta==0.23.0
# homeassistant.components.lutron
pylutron==0.2.16
@@ -2094,7 +2119,7 @@ pymitv==1.4.3
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==3.7.4
+pymodbus==3.8.3
# homeassistant.components.monoprice
pymonoprice==0.4
@@ -2106,7 +2131,7 @@ pymsteams==0.1.12
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==2.1.0
+pynecil==4.0.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -2171,13 +2196,13 @@ pyoverkiz==1.15.5
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
-pypalazzetti==0.1.15
+pypalazzetti==0.1.19
# homeassistant.components.elv
pypca==0.0.7
# homeassistant.components.lcn
-pypck==0.8.1
+pypck==0.8.5
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -2243,7 +2268,7 @@ pyschlage==2024.11.0
pysensibo==1.1.0
# homeassistant.components.serial
-pyserial-asyncio-fast==0.13
+pyserial-asyncio-fast==0.14
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
@@ -2267,7 +2292,7 @@ pysignalclirestapi==0.3.24
pyskyqhub==0.1.4
# homeassistant.components.sma
-pysma==0.7.3
+pysma==0.7.5
# homeassistant.components.smappee
pysmappee==0.2.29
@@ -2285,7 +2310,7 @@ pysmarty2==0.10.1
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.1.4
+pysmlight==0.1.7
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -2303,7 +2328,7 @@ pyspcwebgw==0.7.0
pyspeex-noise==1.0.2
# homeassistant.components.squeezebox
-pysqueezebox==0.10.0
+pysqueezebox==0.11.1
# homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2
@@ -2359,11 +2384,14 @@ python-gc100==1.0.3a0
# homeassistant.components.gitlab_ci
python-gitlab==1.6.0
+# homeassistant.components.google_drive
+python-google-drive-api==0.0.2
+
# homeassistant.components.analytics_insights
python-homeassistant-analytics==0.8.1
# homeassistant.components.homewizard
-python-homewizard-energy==v7.0.1
+python-homewizard-energy==v8.3.2
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2378,16 +2406,16 @@ python-join-api==0.0.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.9.1
+python-kasa[speedups]==0.10.1
# homeassistant.components.linkplay
-python-linkplay==0.1.1
+python-linkplay==0.1.3
# homeassistant.components.lirc
# python-lirc==1.2.3
# homeassistant.components.matter
-python-matter-server==6.6.0
+python-matter-server==7.0.0
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2406,7 +2434,10 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
-python-otbr-api==2.6.0
+python-otbr-api==2.7.0
+
+# homeassistant.components.overseerr
+python-overseerr==0.6.0
# homeassistant.components.picnic
python-picnic-api==1.1.0
@@ -2418,7 +2449,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==2.8.4
+python-roborock==2.11.1
# homeassistant.components.smarttub
python-smarttub==0.0.38
@@ -2427,7 +2458,7 @@ python-smarttub==0.0.38
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.17.6
+python-tado==0.18.5
# homeassistant.components.technove
python-technove==1.3.1
@@ -2469,9 +2500,6 @@ pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
-# homeassistant.components.usb
-pyudev==0.24.1
-
# homeassistant.components.uptimerobot
pyuptimerobot==22.2.0
@@ -2485,7 +2513,7 @@ pyvera==0.3.15
pyversasense==0.0.6
# homeassistant.components.vesync
-pyvesync==2.1.12
+pyvesync==2.1.17
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2535,6 +2563,9 @@ pyzerproc==0.4.8
# homeassistant.components.qbittorrent
qbittorrent-api==2024.2.59
+# homeassistant.components.qbus
+qbusmqttapi==1.2.4
+
# homeassistant.components.qingping
qingping-ble==0.10.0
@@ -2566,13 +2597,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.2.8
+renault-api==0.2.9
# homeassistant.components.renson
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.11.6
+reolink-aio==0.11.9
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2641,7 +2672,7 @@ screenlogicpy==0.10.0
scsgate==0.1.0
# homeassistant.components.backup
-securetar==2024.11.0
+securetar==2025.1.4
# homeassistant.components.sendgrid
sendgrid==6.8.2
@@ -2696,7 +2727,7 @@ sisyphus-control==3.1.4
skyboxremote==0.0.6
# homeassistant.components.slack
-slackclient==2.5.0
+slack_sdk==3.33.4
# homeassistant.components.xmpp
slixmpp==1.8.5
@@ -2705,13 +2736,13 @@ slixmpp==1.8.5
smart-meter-texas==0.5.5
# homeassistant.components.smhi
-smhi-pkg==1.0.18
+smhi-pkg==1.0.19
# homeassistant.components.snapcast
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.6
+soco==0.30.8
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
@@ -2770,7 +2801,7 @@ streamlabswater==1.0.1
stringcase==1.2.0
# homeassistant.components.subaru
-subarulink==0.7.11
+subarulink==0.7.13
# homeassistant.components.sunweg
sunweg==3.0.2
@@ -2823,7 +2854,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.8.5
+tesla-fleet-api==0.9.8
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2832,7 +2863,7 @@ tesla-powerwall==0.5.2
tesla-wall-connector==1.0.2
# homeassistant.components.teslemetry
-teslemetry-stream==0.4.2
+teslemetry-stream==0.6.6
# homeassistant.components.tessie
tessie-api==0.1.1
@@ -2844,7 +2875,7 @@ tessie-api==0.1.1
thermobeacon-ble==0.7.0
# homeassistant.components.thermopro
-thermopro-ble==0.10.0
+thermopro-ble==0.10.1
# homeassistant.components.thingspeak
thingspeak==1.0.0
@@ -2862,16 +2893,16 @@ tilt-ble==0.2.3
tmb==0.0.4
# homeassistant.components.todoist
-todoist-api-python==2.1.2
+todoist-api-python==2.1.7
# homeassistant.components.tolo
-tololib==1.1.0
+tololib==1.2.2
# homeassistant.components.toon
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2024.12
+total-connect-client==2025.1.4
# homeassistant.components.tplink_lte
tp-connected==0.0.4
@@ -2904,13 +2935,13 @@ twilio==6.32.0
twitchAPI==4.2.1
# homeassistant.components.monarch_money
-typedmonarchmoney==0.3.1
+typedmonarchmoney==0.4.4
# homeassistant.components.ukraine_alarm
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==7.4.1
+uiprotect==7.5.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2924,7 +2955,7 @@ unifi_ap==0.0.2
# homeassistant.components.unifiled
unifiled==0.11
-# homeassistant.components.zha
+# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.0.25
# homeassistant.components.upb
@@ -2951,7 +2982,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.12.2
+velbus-aio==2025.1.1
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2960,7 +2991,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
-voip-utils==0.2.2
+voip-utils==0.3.1
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
@@ -3009,10 +3040,10 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2024.12.22
+weheat==2025.1.15
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.11
+whirlpool-sixth-sense==0.18.12
# homeassistant.components.whois
whois==0.9.27
@@ -3039,7 +3070,7 @@ xbox-webapi==2.1.0
xiaomi-ble==0.33.0
# homeassistant.components.knx
-xknx==3.4.0
+xknx==3.5.0
# homeassistant.components.knx
xknxproject==3.8.1
@@ -3067,7 +3098,7 @@ yalexs-ble==2.5.6
yalexs==8.10.0
# homeassistant.components.yeelight
-yeelight==0.7.14
+yeelight==0.7.16
# homeassistant.components.yeelightsunflower
yeelightsunflower==0.0.10
@@ -3076,13 +3107,13 @@ yeelightsunflower==0.0.10
yolink-api==0.4.7
# homeassistant.components.youless
-youless-api==2.1.2
+youless-api==2.2.0
# homeassistant.components.youtube
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2025.01.15
+yt-dlp[default]==2025.01.26
# homeassistant.components.zabbix
zabbix-utils==2.0.2
@@ -3094,13 +3125,13 @@ zamg==0.3.6
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.136.2
+zeroconf==0.143.0
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.45
+zha==0.0.47
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
diff --git a/requirements_test.txt b/requirements_test.txt
index 2a6841ada2a..cf0a1e5473f 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,15 +7,15 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
-astroid==3.3.6
+astroid==3.3.8
coverage==7.6.8
freezegun==1.5.1
license-expression==30.4.0
mock-open==1.4.0
-mypy-dev==1.14.0a7
+mypy-dev==1.16.0a1
pre-commit==4.0.0
-pydantic==2.10.4
-pylint==3.3.2
+pydantic==2.10.6
+pylint==3.3.3
pylint-per-file-ignores==1.3.2
pipdeptree==2.23.4
pytest-asyncio==0.24.0
@@ -31,23 +31,25 @@ pytest-picked==0.5.0
pytest-xdist==3.6.1
pytest==8.3.4
requests-mock==1.12.1
-respx==0.21.1
+respx==0.22.0
syrupy==4.8.0
tqdm==4.66.5
-types-aiofiles==24.1.0.20240626
+types-aiofiles==24.1.0.20241221
types-atomicwrites==1.4.5.1
-types-croniter==4.0.0.20241030
+types-croniter==5.0.1.20241205
types-beautifulsoup4==4.12.0.20241020
types-caldav==1.3.0.20241107
types-chardet==0.1.5
types-decorator==5.1.8.20240310
types-paho-mqtt==1.6.0.20240321
+types-pexpect==4.9.0.20241208
types-pillow==10.2.0.20240822
types-protobuf==5.29.1.20241207
-types-psutil==6.1.0.20241102
-types-python-dateutil==2.9.0.20241003
+types-psutil==6.1.0.20241221
+types-pyserial==3.5.0.20241221
+types-python-dateutil==2.9.0.20241206
types-python-slugify==8.0.2.20240310
-types-pytz==2024.2.0.20241003
-types-PyYAML==6.0.12.20240917
+types-pytz==2024.2.0.20241221
+types-PyYAML==6.0.12.20241230
types-requests==2.31.0.3
types-xmltodict==0.13.0.3
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 5d63c7a5c61..03779787e33 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -7,7 +7,7 @@
AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
-AIOSomecomfort==0.0.28
+AIOSomecomfort==0.0.32
# homeassistant.components.adax
Adax-local==0.1.5
@@ -33,7 +33,7 @@ Mastodon.py==1.8.1
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
-Pillow==11.0.0
+Pillow==11.1.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -94,7 +94,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.39.1
+PyViCare==2.41.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -103,20 +103,20 @@ PyXiaomiGateway==0.14.3
RachioPy==1.1.0
# homeassistant.components.python_script
-RestrictedPython==7.4
+RestrictedPython==8.0
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.36
+SQLAlchemy==2.0.37
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
# homeassistant.components.onvif
-WSDiscovery==2.0.0
+WSDiscovery==2.1.2
# homeassistant.components.accuweather
accuweather==4.0.0
@@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3
aio-georss-gdacs==0.10
# homeassistant.components.acaia
-aioacaia==0.1.13
+aioacaia==0.1.14
# homeassistant.components.airq
aioairq==0.4.3
@@ -189,7 +189,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2025.1.0
+aioautomower==2025.1.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -204,7 +204,7 @@ aiobotocore==2.13.1
aiocomelit==0.10.1
# homeassistant.components.dhcp
-aiodhcpwatcher==1.0.2
+aiodhcpwatcher==1.0.3
# homeassistant.components.dhcp
aiodiscover==2.1.0
@@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==28.0.0
+aioesphomeapi==29.0.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -243,19 +243,22 @@ aiogithubapi==24.6.0
aioguardian==2022.07.0
# homeassistant.components.harmony
-aioharmony==0.2.10
+aioharmony==0.4.1
# homeassistant.components.hassio
-aiohasupervisor==0.2.2b5
+aiohasupervisor==0.3.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.7
+# homeassistant.components.mcp_server
+aiohttp_sse==2.2.0
+
# homeassistant.components.hue
aiohue==4.7.3
# homeassistant.components.imap
-aioimaplib==1.1.0
+aioimaplib==2.0.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
@@ -264,7 +267,7 @@ aiokafka==0.10.0
aiolifx-effects==0.3.2
# homeassistant.components.lifx
-aiolifx-themes==0.6.0
+aiolifx-themes==0.6.4
# homeassistant.components.lifx
aiolifx==1.1.2
@@ -338,7 +341,7 @@ aioridwell==2024.01.0
aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.1.1
+aiorussound==4.4.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -347,7 +350,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.2.0
+aioshelly==12.3.2
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -365,7 +368,7 @@ aiosteamist==1.0.0
aiostreammagic==2.10.0
# homeassistant.components.switcher_kis
-aioswitcher==5.1.0
+aioswitcher==6.0.0
# homeassistant.components.syncthing
aiosyncthing==0.5.1
@@ -382,6 +385,9 @@ aiotractive==0.6.0
# homeassistant.components.unifi
aiounifi==81
+# homeassistant.components.usb
+aiousbwatcher==1.1.1
+
# homeassistant.components.vlc_telnet
aiovlc==0.5.1
@@ -395,7 +401,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webostv
-aiowebostv==0.4.2
+aiowebostv==0.6.1
# homeassistant.components.withings
aiowithings==3.1.5
@@ -437,7 +443,10 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
-anthropic==0.31.2
+anthropic==0.44.0
+
+# homeassistant.components.mcp_server
+anyio==4.8.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -452,7 +461,7 @@ aprslib==0.7.2
apsystems-ez1==2.4.0
# homeassistant.components.aranet
-aranet4==2.4.0
+aranet4==2.5.1
# homeassistant.components.arcam_fmj
arcam-fmj==1.5.2
@@ -463,7 +472,7 @@ arcam-fmj==1.5.2
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.42.0
+async-upnp-client==0.43.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -491,7 +500,7 @@ av==13.1.0
axis==64
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.4
+ayla-iot-unofficial==1.4.5
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -516,10 +525,10 @@ bimmer-connected[china]==0.17.2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==2.0.0
+bleak-esphome==2.7.0
# homeassistant.components.bluetooth
-bleak-retry-connector==3.6.0
+bleak-retry-connector==3.8.1
# homeassistant.components.bluetooth
bleak==0.22.3
@@ -537,7 +546,7 @@ bluecurrent-api==1.2.3
bluemaestro-ble==0.2.3
# homeassistant.components.bluetooth
-bluetooth-adapters==0.20.2
+bluetooth-adapters==0.21.4
# homeassistant.components.bluetooth
bluetooth-auto-recovery==1.4.2
@@ -546,7 +555,7 @@ bluetooth-auto-recovery==1.4.2
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.20.0
+bluetooth-data-tools==1.23.4
# homeassistant.components.bond
bond-async==0.2.1
@@ -558,7 +567,7 @@ boschshcpy==0.2.91
botocore==1.34.131
# homeassistant.components.bring
-bring-api==0.9.1
+bring-api==1.0.0
# homeassistant.components.broadlink
broadlink==0.19.0
@@ -573,7 +582,7 @@ brottsplatskartan==1.0.5
brunt==1.2.0
# homeassistant.components.bthome
-bthome-ble==3.9.1
+bthome-ble==3.12.3
# homeassistant.components.buienradar
buienradar==1.0.6
@@ -622,13 +631,13 @@ datadog==0.15.0
datapoint==0.9.9
# homeassistant.components.bluetooth
-dbus-fast==2.24.3
+dbus-fast==2.33.0
# homeassistant.components.debugpy
debugpy==1.8.11
# homeassistant.components.ecovacs
-deebot-client==11.0.0
+deebot-client==12.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -645,7 +654,7 @@ demetriek==1.2.0
denonavr==1.0.1
# homeassistant.components.devialet
-devialet==1.4.5
+devialet==1.5.7
# homeassistant.components.devolo_home_control
devolo-home-control-api==0.18.3
@@ -687,7 +696,7 @@ eagle100==0.1.1
easyenergy==2.1.2
# homeassistant.components.eheimdigital
-eheimdigital==1.0.3
+eheimdigital==1.0.5
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
@@ -783,11 +792,11 @@ flexit_bacnet==2.2.1
flipr-api==1.6.1
# homeassistant.components.flux_led
-flux-led==1.1.0
+flux-led==1.1.3
# homeassistant.components.homekit
# homeassistant.components.recorder
-fnv-hash-fast==1.0.2
+fnv-hash-fast==1.2.2
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -806,7 +815,7 @@ fritzconnection[qr]==1.14.0
fyta_cli==0.7.0
# homeassistant.components.google_translate
-gTTS==2.2.4
+gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
gardena-bluetooth==1.5.0
@@ -815,7 +824,7 @@ gardena-bluetooth==1.5.0
gassist-text==0.0.11
# homeassistant.components.google
-gcal-sync==6.2.0
+gcal-sync==7.0.0
# homeassistant.components.geniushub
geniushub-client==0.7.1
@@ -840,7 +849,7 @@ georss-qld-bushfire-alert-client==0.8
# homeassistant.components.nmap_tracker
# homeassistant.components.samsungtv
# homeassistant.components.upnp
-getmac==0.9.4
+getmac==0.9.5
# homeassistant.components.gios
gios==5.0.0
@@ -874,7 +883,7 @@ google-cloud-texttospeech==2.17.2
google-generativeai==0.8.2
# homeassistant.components.nest
-google-nest-sdm==6.1.5
+google-nest-sdm==7.1.1
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -890,7 +899,7 @@ goslide-api==0.7.0
gotailwind==0.3.0
# homeassistant.components.govee_ble
-govee-ble==0.40.0
+govee-ble==0.42.0
# homeassistant.components.govee_light_local
govee-local-api==1.5.3
@@ -914,7 +923,7 @@ growattServer==1.5.0
gspread==5.5.0
# homeassistant.components.profiler
-guppy3==3.1.4.post1;python_version<'3.13'
+guppy3==3.1.5
# homeassistant.components.iaqualink
h2==4.1.0
@@ -929,16 +938,16 @@ ha-iotawattpy==0.1.2
ha-philipsjs==3.2.2
# homeassistant.components.habitica
-habitipy==0.3.3
+habiticalib==0.3.4
# homeassistant.components.bluetooth
-habluetooth==3.7.0
+habluetooth==3.21.1
# homeassistant.components.cloud
-hass-nabucasa==0.87.0
+hass-nabucasa==0.88.1
# homeassistant.components.conversation
-hassil==2.1.0
+hassil==2.2.3
# homeassistant.components.jewish_calendar
hdate==0.11.1
@@ -963,16 +972,16 @@ hole==0.8.0
holidays==0.65
# homeassistant.components.frontend
-home-assistant-frontend==20250109.2
+home-assistant-frontend==20250205.0
# homeassistant.components.conversation
-home-assistant-intents==2025.1.1
+home-assistant-intents==2025.2.5
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
-homematicip==1.1.5
+homematicip==1.1.7
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -995,7 +1004,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
-ical==8.2.0
+ical==8.3.0
# homeassistant.components.caldav
icalendar==6.1.0
@@ -1009,11 +1018,14 @@ idasen-ha==2.6.3
# homeassistant.components.network
ifaddr==0.2.0
+# homeassistant.components.igloohome
+igloohome-api==0.1.0
+
# homeassistant.components.imgw_pib
-imgw_pib==1.0.7
+imgw_pib==1.0.9
# homeassistant.components.incomfort
-incomfort-client==0.6.4
+incomfort-client==0.6.7
# homeassistant.components.influxdb
influxdb-client==1.24.0
@@ -1046,7 +1058,7 @@ israel-rail-api==0.1.2
jaraco.abode==6.2.1
# homeassistant.components.jellyfin
-jellyfin-apiclient-python==1.9.2
+jellyfin-apiclient-python==1.10.0
# homeassistant.components.command_line
# homeassistant.components.rest
@@ -1062,7 +1074,7 @@ kegtron-ble==0.4.0
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2025.1.18.164225
+knx-frontend==2025.1.30.194235
# homeassistant.components.konnected
konnected==1.2.0
@@ -1071,13 +1083,13 @@ konnected==1.2.0
krakenex==2.2.2
# homeassistant.components.lacrosse_view
-lacrosse-view==1.0.3
+lacrosse-view==1.0.4
# homeassistant.components.laundrify
laundrify-aio==1.2.2
# homeassistant.components.lcn
-lcn-frontend==0.2.2
+lcn-frontend==0.2.3
# homeassistant.components.ld2410_ble
ld2410-ble==0.1.1
@@ -1086,11 +1098,14 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
-led-ble==1.1.1
+led-ble==1.1.6
# homeassistant.components.lektrico
lektricowifi==0.0.43
+# homeassistant.components.letpot
+letpot==0.3.0
+
# homeassistant.components.foscam
libpyfoscam==1.2.2
@@ -1130,6 +1145,10 @@ maxcube-api==0.4.3
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
+# homeassistant.components.mcp
+# homeassistant.components.mcp_server
+mcp==1.1.2
+
# homeassistant.components.minecraft_server
mcstatus==11.1.1
@@ -1158,7 +1177,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
-millheater==0.12.2
+millheater==0.12.3
# homeassistant.components.minio
minio==7.1.12
@@ -1203,7 +1222,7 @@ mutesync==0.0.1
mypermobil==0.1.8
# homeassistant.components.myuplink
-myuplink==0.6.0
+myuplink==0.7.0
# homeassistant.components.keenetic_ndms2
ndms2-client==0.1.2
@@ -1230,13 +1249,13 @@ nextcord==2.6.0
nextdns==4.0.0
# homeassistant.components.niko_home_control
-nhc==0.3.4
+nhc==0.3.9
# homeassistant.components.nibe_heatpump
nibe==2.14.0
# homeassistant.components.nice_go
-nice-go==1.0.0
+nice-go==1.0.1
# homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5
@@ -1258,7 +1277,7 @@ numato-gpio==0.13.0
# homeassistant.components.stream
# homeassistant.components.tensorflow
# homeassistant.components.trend
-numpy==2.2.0
+numpy==2.2.2
# homeassistant.components.nyt_games
nyt_games==0.4.4
@@ -1273,10 +1292,10 @@ objgraph==3.5.0
odp-amsterdam==6.0.2
# homeassistant.components.ohme
-ohme==1.2.0
+ohme==1.2.8
# homeassistant.components.ollama
-ollama==0.4.5
+ollama==0.4.7
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -1284,8 +1303,11 @@ omnilogic==0.4.5
# homeassistant.components.ondilo_ico
ondilo==0.5.0
+# homeassistant.components.onedrive
+onedrive-personal-sdk==0.0.8
+
# homeassistant.components.onvif
-onvif-zeep-async==3.2.3
+onvif-zeep-async==3.2.5
# homeassistant.components.opengarage
open-garage==0.2.0
@@ -1294,7 +1316,7 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.openai_conversation
-openai==1.35.7
+openai==1.59.9
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1306,7 +1328,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
-opower==0.8.7
+opower==0.8.9
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1333,7 +1355,7 @@ pdunehd==1.3.2
peblar==0.4.0
# homeassistant.components.peco
-peco==0.0.30
+peco==0.1.2
# homeassistant.components.escea
pescea==1.0.12
@@ -1377,7 +1399,7 @@ prometheus-client==0.21.0
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
-psutil==6.1.0
+psutil==6.1.1
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -1422,7 +1444,7 @@ py-nightscout==1.2.2
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.5.3
+py-synologydsm-api==2.6.0
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -1436,6 +1458,9 @@ pyDuotecno==2024.10.1
# homeassistant.components.electrasmart
pyElectra==1.2.4
+# homeassistant.components.homee
+pyHomee==1.2.5
+
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1522,7 +1547,7 @@ pydexcom==0.2.3
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.12.0
+pydrawise==2025.1.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1549,7 +1574,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.23.0
+pyenphase==1.23.1
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1579,7 +1604,7 @@ pyforked-daapd==0.1.14
pyfreedompro==1.1.0
# homeassistant.components.fritzbox
-pyfritzhome==0.6.12
+pyfritzhome==0.6.14
# homeassistant.components.ifttt
pyfttt==0.3
@@ -1591,7 +1616,7 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
-pyheos==0.7.2
+pyheos==1.0.1
# homeassistant.components.hive
pyhive-integration==1.0.1
@@ -1612,7 +1637,7 @@ pyicloud==1.0.0
pyinsteon==1.6.3
# homeassistant.components.ipma
-pyipma==3.0.7
+pyipma==3.0.8
# homeassistant.components.ipp
pyipp==0.17.0
@@ -1621,7 +1646,7 @@ pyipp==0.17.0
pyiqvia==2022.04.0
# homeassistant.components.iskra
-pyiskra==0.1.14
+pyiskra==0.1.15
# homeassistant.components.iss
pyiss==1.0.1
@@ -1675,10 +1700,10 @@ pylibrespot-java==0.1.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
-pylitterbot==2023.5.0
+pylitterbot==2024.0.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.22.0
+pylutron-caseta==0.23.0
# homeassistant.components.lutron
pylutron==0.2.16
@@ -1702,7 +1727,7 @@ pymicro-vad==1.0.1
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==3.7.4
+pymodbus==3.8.3
# homeassistant.components.monoprice
pymonoprice==0.4
@@ -1711,7 +1736,7 @@ pymonoprice==0.4
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==2.1.0
+pynecil==4.0.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -1767,10 +1792,10 @@ pyoverkiz==1.15.5
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
-pypalazzetti==0.1.15
+pypalazzetti==0.1.19
# homeassistant.components.lcn
-pypck==0.8.1
+pypck==0.8.5
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -1796,6 +1821,9 @@ pyps4-2ndscreen==1.3.1
# homeassistant.components.qwikswitch
pyqwikswitch==0.93
+# homeassistant.components.nmbs
+pyrail==0.0.3
+
# homeassistant.components.rainbird
pyrainbird==6.0.1
@@ -1836,7 +1864,7 @@ pysiaalarm==3.1.1
pysignalclirestapi==0.3.24
# homeassistant.components.sma
-pysma==0.7.3
+pysma==0.7.5
# homeassistant.components.smappee
pysmappee==0.2.29
@@ -1854,7 +1882,7 @@ pysmarty2==0.10.1
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.1.4
+pysmlight==0.1.7
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -1872,7 +1900,7 @@ pyspcwebgw==0.7.0
pyspeex-noise==1.0.2
# homeassistant.components.squeezebox
-pysqueezebox==0.10.0
+pysqueezebox==0.11.1
# homeassistant.components.suez_water
pysuezV2==2.0.3
@@ -1901,11 +1929,14 @@ python-fullykiosk==0.0.14
# homeassistant.components.sms
# python-gammu==3.2.4
+# homeassistant.components.google_drive
+python-google-drive-api==0.0.2
+
# homeassistant.components.analytics_insights
python-homeassistant-analytics==0.8.1
# homeassistant.components.homewizard
-python-homewizard-energy==v7.0.1
+python-homewizard-energy==v8.3.2
# homeassistant.components.izone
python-izone==1.2.9
@@ -1914,13 +1945,13 @@ python-izone==1.2.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.9.1
+python-kasa[speedups]==0.10.1
# homeassistant.components.linkplay
-python-linkplay==0.1.1
+python-linkplay==0.1.3
# homeassistant.components.matter
-python-matter-server==6.6.0
+python-matter-server==7.0.0
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -1939,7 +1970,10 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
-python-otbr-api==2.6.0
+python-otbr-api==2.7.0
+
+# homeassistant.components.overseerr
+python-overseerr==0.6.0
# homeassistant.components.picnic
python-picnic-api==1.1.0
@@ -1948,7 +1982,7 @@ python-picnic-api==1.1.0
python-rabbitair==0.0.8
# homeassistant.components.roborock
-python-roborock==2.8.4
+python-roborock==2.11.1
# homeassistant.components.smarttub
python-smarttub==0.0.38
@@ -1957,7 +1991,7 @@ python-smarttub==0.0.38
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.17.6
+python-tado==0.18.5
# homeassistant.components.technove
python-technove==1.3.1
@@ -1990,9 +2024,6 @@ pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
-# homeassistant.components.usb
-pyudev==0.24.1
-
# homeassistant.components.uptimerobot
pyuptimerobot==22.2.0
@@ -2000,7 +2031,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.15
# homeassistant.components.vesync
-pyvesync==2.1.12
+pyvesync==2.1.17
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2044,6 +2075,9 @@ pyzerproc==0.4.8
# homeassistant.components.qbittorrent
qbittorrent-api==2024.2.59
+# homeassistant.components.qbus
+qbusmqttapi==1.2.4
+
# homeassistant.components.qingping
qingping-ble==0.10.0
@@ -2066,13 +2100,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.2.8
+renault-api==0.2.9
# homeassistant.components.renson
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.11.6
+reolink-aio==0.11.9
# homeassistant.components.rflink
rflink==0.0.66
@@ -2120,7 +2154,7 @@ sanix==1.0.6
screenlogicpy==0.10.0
# homeassistant.components.backup
-securetar==2024.11.0
+securetar==2025.1.4
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
@@ -2163,19 +2197,19 @@ simplisafe-python==2024.01.0
skyboxremote==0.0.6
# homeassistant.components.slack
-slackclient==2.5.0
+slack_sdk==3.33.4
# homeassistant.components.smart_meter_texas
smart-meter-texas==0.5.5
# homeassistant.components.smhi
-smhi-pkg==1.0.18
+smhi-pkg==1.0.19
# homeassistant.components.snapcast
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.6
+soco==0.30.8
# homeassistant.components.solarlog
solarlog_cli==0.4.0
@@ -2228,7 +2262,7 @@ streamlabswater==1.0.1
stringcase==1.2.0
# homeassistant.components.subaru
-subarulink==0.7.11
+subarulink==0.7.13
# homeassistant.components.sunweg
sunweg==3.0.2
@@ -2260,7 +2294,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.8.5
+tesla-fleet-api==0.9.8
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2269,7 +2303,7 @@ tesla-powerwall==0.5.2
tesla-wall-connector==1.0.2
# homeassistant.components.teslemetry
-teslemetry-stream==0.4.2
+teslemetry-stream==0.6.6
# homeassistant.components.tessie
tessie-api==0.1.1
@@ -2278,7 +2312,7 @@ tessie-api==0.1.1
thermobeacon-ble==0.7.0
# homeassistant.components.thermopro
-thermopro-ble==0.10.0
+thermopro-ble==0.10.1
# homeassistant.components.lg_thinq
thinqconnect==1.0.2
@@ -2287,16 +2321,16 @@ thinqconnect==1.0.2
tilt-ble==0.2.3
# homeassistant.components.todoist
-todoist-api-python==2.1.2
+todoist-api-python==2.1.7
# homeassistant.components.tolo
-tololib==1.1.0
+tololib==1.2.2
# homeassistant.components.toon
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2024.12
+total-connect-client==2025.1.4
# homeassistant.components.tplink_omada
tplink-omada-client==1.4.3
@@ -2326,13 +2360,13 @@ twilio==6.32.0
twitchAPI==4.2.1
# homeassistant.components.monarch_money
-typedmonarchmoney==0.3.1
+typedmonarchmoney==0.4.4
# homeassistant.components.ukraine_alarm
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==7.4.1
+uiprotect==7.5.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2340,9 +2374,6 @@ ultraheat-api==0.5.7
# homeassistant.components.unifiprotect
unifi-discovery==1.2.0
-# homeassistant.components.zha
-universal-silabs-flasher==0.0.25
-
# homeassistant.components.upb
upb-lib==0.5.9
@@ -2367,7 +2398,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.12.2
+velbus-aio==2025.1.1
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2376,7 +2407,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
-voip-utils==0.2.2
+voip-utils==0.3.1
# homeassistant.components.volvooncall
volvooncall==0.10.3
@@ -2413,10 +2444,10 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2024.12.22
+weheat==2025.1.15
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.11
+whirlpool-sixth-sense==0.18.12
# homeassistant.components.whois
whois==0.9.27
@@ -2440,7 +2471,7 @@ xbox-webapi==2.1.0
xiaomi-ble==0.33.0
# homeassistant.components.knx
-xknx==3.4.0
+xknx==3.5.0
# homeassistant.components.knx
xknxproject==3.8.1
@@ -2465,31 +2496,31 @@ yalexs-ble==2.5.6
yalexs==8.10.0
# homeassistant.components.yeelight
-yeelight==0.7.14
+yeelight==0.7.16
# homeassistant.components.yolink
yolink-api==0.4.7
# homeassistant.components.youless
-youless-api==2.1.2
+youless-api==2.2.0
# homeassistant.components.youtube
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2025.01.15
+yt-dlp[default]==2025.01.26
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zeroconf
-zeroconf==0.136.2
+zeroconf==0.143.0
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.45
+zha==0.0.47
# homeassistant.components.zwave_js
zwave-js-server-python==0.60.0
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index dcddf267eb4..4dd3bc46010 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.8.3
+ruff==0.9.1
yamllint==1.35.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 86179ac228f..ef57b9140ce 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -139,16 +139,16 @@ uuid==1000000000.0.0
# 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
# requirements so we can directly link HA versions to these library versions.
-anyio==4.7.0
+anyio==4.8.0
h11==0.14.0
-httpcore==1.0.5
+httpcore==1.0.7
# Ensure we have a hyperframe version that works in Python 3.10
# 5.2.0 fixed a collections abc deprecation
hyperframe>=5.2.0
# Ensure we run compatible with musllinux build env
-numpy==2.2.0
+numpy==2.2.2
pandas~=2.2.3
# Constrain multidict to avoid typing issues
@@ -159,7 +159,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
-pydantic==2.10.4
+pydantic==2.10.6
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py
index 83d406a0036..f842ec61b97 100644
--- a/script/hassfest/config_flow.py
+++ b/script/hassfest/config_flow.py
@@ -231,8 +231,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
if integrations_path.read_text() != content + "\n":
config.add_error(
"config_flow",
- "File integrations.json is not up to date. "
- "Run python3 -m script.hassfest",
+ "File integrations.json is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
index 62644e19c5e..d29571eaa83 100644
--- a/script/hassfest/dependencies.py
+++ b/script/hassfest/dependencies.py
@@ -168,6 +168,7 @@ IGNORE_VIOLATIONS = {
("zha", "homeassistant_sky_connect"),
("zha", "homeassistant_yellow"),
("homeassistant_sky_connect", "zha"),
+ ("homeassistant_hardware", "zha"),
# This should become a helper method that integrations can submit data to
("websocket_api", "lovelace"),
("websocket_api", "shopping_list"),
diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py
index 022caee30cd..edc47e2f9d7 100644
--- a/script/hassfest/docker.py
+++ b/script/hassfest/docker.py
@@ -94,6 +94,8 @@ COPY . /usr/src/homeassistant
# Uv is only needed during build
RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \
+ # Uv creates a lock file in /tmp
+ --mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG
apk add --no-cache libturbojpeg \
&& uv pip install \
diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile
index 962ab58d981..6c865612f1a 100644
--- a/script/hassfest/docker/Dockerfile
+++ b/script/hassfest/docker/Dockerfile
@@ -14,7 +14,9 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
-RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \
+RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \
+ # Uv creates a lock file in /tmp
+ --mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG
apk add --no-cache libturbojpeg \
&& uv pip install \
@@ -22,8 +24,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \
--no-cache \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \
- stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.3 \
- PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
+ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \
+ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant "
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index fdbcf5bcb78..6e9cd8bdedc 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -27,6 +27,8 @@ DOCUMENTATION_URL_HOST = "www.home-assistant.io"
DOCUMENTATION_URL_PATH_PREFIX = "/integrations/"
DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"}
+_CORE_DOCUMENTATION_BASE = "https://www.home-assistant.io/integrations"
+
class NonScaledQualityScaleTiers(StrEnum):
"""Supported manifest quality scales."""
@@ -117,19 +119,26 @@ NO_IOT_CLASS = [
]
-def documentation_url(value: str) -> str:
+def core_documentation_url(value: str) -> str:
"""Validate that a documentation url has the correct path and domain."""
if value in DOCUMENTATION_URL_EXCEPTIONS:
return value
+ if not value.startswith(_CORE_DOCUMENTATION_BASE):
+ raise vol.Invalid(
+ f"Documentation URL does not begin with {_CORE_DOCUMENTATION_BASE}"
+ )
+ return value
+
+
+def custom_documentation_url(value: str) -> str:
+ """Validate that a custom integration documentation url is correct."""
parsed_url = urlparse(value)
if parsed_url.scheme != DOCUMENTATION_URL_SCHEMA:
raise vol.Invalid("Documentation url is not prefixed with https")
- if parsed_url.netloc == DOCUMENTATION_URL_HOST and not parsed_url.path.startswith(
- DOCUMENTATION_URL_PATH_PREFIX
- ):
+ if value.startswith(_CORE_DOCUMENTATION_BASE):
raise vol.Invalid(
- "Documentation url does not begin with www.home-assistant.io/integrations"
+ "Documentation URL should point to the custom integration documentation"
)
return value
@@ -258,7 +267,7 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
}
)
],
- vol.Required("documentation"): vol.All(vol.Url(), documentation_url),
+ vol.Required("documentation"): vol.All(vol.Url(), core_documentation_url),
vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES),
vol.Optional("requirements"): [str],
vol.Optional("dependencies"): [str],
@@ -293,6 +302,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema:
CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend(
{
+ vol.Required("documentation"): vol.All(vol.Url(), custom_documentation_url),
vol.Optional("version"): vol.All(str, verify_version),
vol.Optional("issue_tracker"): vol.Url(),
vol.Optional("import_executor"): bool,
diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py
index 1d7f2b5ed88..ac27df85ccc 100644
--- a/script/hassfest/mypy_config.py
+++ b/script/hassfest/mypy_config.py
@@ -41,7 +41,7 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
),
"show_error_codes": "true",
"follow_imports": "normal",
- # "enable_incomplete_feature": ", ".join( # noqa: FLY002
+ # "enable_incomplete_feature": ", ".join(
# []
# ),
# Enable some checks globally.
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index 3d880d7b536..a1ad52e6aa8 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -218,7 +218,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"bluetooth_adapters",
"bluetooth_le_tracker",
"bluetooth_tracker",
- "bmw_connected_drive",
"bond",
"bosch_shc",
"braviatv",
@@ -562,7 +561,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"kitchen_sink",
"kiwi",
"kmtronic",
- "knx",
"kodi",
"konnected",
"kostal_plenticore",
@@ -597,7 +595,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"linux_battery",
"lirc",
"litejet",
- "litterrobot",
"livisi",
"llamalab_automate",
"local_calendar",
@@ -654,7 +651,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"mikrotik",
"mill",
"min_max",
- "minecraft_server",
"minio",
"mjpeg",
"moat",
@@ -743,7 +739,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"omnilogic",
"oncue",
"ondilo_ico",
- "onewire",
"onvif",
"open_meteo",
"openai_conversation",
@@ -878,6 +873,1091 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"rtorrent",
"rtsp_to_webrtc",
"ruckus_unleashed",
+ "ruuvi_gateway",
+ "ruuvitag_ble",
+ "rympro",
+ "saj",
+ "samsungtv",
+ "sanix",
+ "satel_integra",
+ "schlage",
+ "schluter",
+ "scrape",
+ "screenlogic",
+ "scsgate",
+ "season",
+ "sendgrid",
+ "sense",
+ "sensirion_ble",
+ "sensorpro",
+ "sensorpush",
+ "sensoterra",
+ "sentry",
+ "senz",
+ "serial",
+ "serial_pm",
+ "sesame",
+ "seven_segments",
+ "seventeentrack",
+ "sfr_box",
+ "sharkiq",
+ "shell_command",
+ "shelly",
+ "shodan",
+ "shopping_list",
+ "sia",
+ "sigfox",
+ "sighthound",
+ "signal_messenger",
+ "simplefin",
+ "simplepush",
+ "simplisafe",
+ "simulated",
+ "sinch",
+ "sisyphus",
+ "sky_hub",
+ "sky_remote",
+ "skybeacon",
+ "skybell",
+ "slack",
+ "sleepiq",
+ "slide",
+ "slimproto",
+ "sma",
+ "smappee",
+ "smart_meter_texas",
+ "smartthings",
+ "smarttub",
+ "smarty",
+ "smhi",
+ "smlight",
+ "sms",
+ "smtp",
+ "snapcast",
+ "snips",
+ "snmp",
+ "snooz",
+ "solaredge",
+ "solaredge_local",
+ "solax",
+ "soma",
+ "somfy_mylink",
+ "sonarr",
+ "songpal",
+ "sonos",
+ "sony_projector",
+ "soundtouch",
+ "spaceapi",
+ "spc",
+ "speedtestdotnet",
+ "spider",
+ "splunk",
+ "spotify",
+ "sql",
+ "squeezebox",
+ "srp_energy",
+ "ssdp",
+ "starline",
+ "starlingbank",
+ "starlink",
+ "startca",
+ "statistics",
+ "statsd",
+ "steam_online",
+ "steamist",
+ "stiebel_eltron",
+ "stream",
+ "streamlabswater",
+ "subaru",
+ "sun",
+ "sunweg",
+ "supervisord",
+ "supla",
+ "surepetcare",
+ "swiss_hydrological_data",
+ "swisscom",
+ "switch_as_x",
+ "switchbee",
+ "switchbot_cloud",
+ "switcher_kis",
+ "switchmate",
+ "syncthing",
+ "syncthru",
+ "synology_chat",
+ "synology_dsm",
+ "synology_srm",
+ "syslog",
+ "system_bridge",
+ "systemmonitor",
+ "tado",
+ "tailscale",
+ "tami4",
+ "tank_utility",
+ "tankerkoenig",
+ "tapsaff",
+ "tasmota",
+ "tautulli",
+ "tcp",
+ "technove",
+ "ted5000",
+ "telegram",
+ "telegram_bot",
+ "tellduslive",
+ "tellstick",
+ "telnet",
+ "temper",
+ "template",
+ "tensorflow",
+ "tesla_fleet",
+ "tesla_wall_connector",
+ "teslemetry",
+ "tessie",
+ "tfiac",
+ "thermobeacon",
+ "thermopro",
+ "thermoworks_smoke",
+ "thethingsnetwork",
+ "thingspeak",
+ "thinkingcleaner",
+ "thomson",
+ "thread",
+ "threshold",
+ "tibber",
+ "tikteck",
+ "tile",
+ "tilt_ble",
+ "time_date",
+ "tmb",
+ "tod",
+ "todoist",
+ "tolo",
+ "tomato",
+ "tomorrowio",
+ "toon",
+ "torque",
+ "touchline",
+ "touchline_sl",
+ "tplink_lte",
+ "tplink_omada",
+ "traccar",
+ "traccar_server",
+ "tractive",
+ "tradfri",
+ "trafikverket_camera",
+ "trafikverket_ferry",
+ "trafikverket_train",
+ "trafikverket_weatherstation",
+ "transmission",
+ "transport_nsw",
+ "travisci",
+ "trend",
+ "triggercmd",
+ "tuya",
+ "twilio",
+ "twilio_call",
+ "twilio_sms",
+ "twinkly",
+ "twitch",
+ "twitter",
+ "ubus",
+ "uk_transport",
+ "ukraine_alarm",
+ "unifi",
+ "unifi_direct",
+ "unifiled",
+ "unifiprotect",
+ "universal",
+ "upb",
+ "upc_connect",
+ "upcloud",
+ "upnp",
+ "uptime",
+ "uptimerobot",
+ "usb",
+ "usgs_earthquakes_feed",
+ "utility_meter",
+ "uvc",
+ "v2c",
+ "vallox",
+ "vasttrafik",
+ "velux",
+ "venstar",
+ "vera",
+ "verisure",
+ "versasense",
+ "version",
+ "vesync",
+ "viaggiatreno",
+ "vilfo",
+ "vivotek",
+ "vizio",
+ "vlc",
+ "vlc_telnet",
+ "vodafone_station",
+ "voicerss",
+ "voip",
+ "volkszaehler",
+ "volumio",
+ "volvooncall",
+ "vulcan",
+ "vultr",
+ "w800rf32",
+ "wake_on_lan",
+ "wallbox",
+ "waqi",
+ "waterfurnace",
+ "watson_iot",
+ "watson_tts",
+ "watttime",
+ "waze_travel_time",
+ "weatherflow",
+ "weatherflow_cloud",
+ "weatherkit",
+ "webmin",
+ "wemo",
+ "whirlpool",
+ "whois",
+ "wiffi",
+ "wilight",
+ "wirelesstag",
+ "withings",
+ "wiz",
+ "wled",
+ "wmspro",
+ "wolflink",
+ "workday",
+ "worldclock",
+ "worldtidesinfo",
+ "worxlandroid",
+ "ws66i",
+ "wsdot",
+ "wyoming",
+ "x10",
+ "xbox",
+ "xeoma",
+ "xiaomi",
+ "xiaomi_aqara",
+ "xiaomi_ble",
+ "xiaomi_miio",
+ "xiaomi_tv",
+ "xmpp",
+ "xs1",
+ "yale",
+ "yale_smart_alarm",
+ "yalexs_ble",
+ "yamaha",
+ "yamaha_musiccast",
+ "yandex_transport",
+ "yandextts",
+ "yardian",
+ "yeelight",
+ "yeelightsunflower",
+ "yi",
+ "yolink",
+ "youless",
+ "youtube",
+ "zabbix",
+ "zamg",
+ "zengge",
+ "zeroconf",
+ "zerproc",
+ "zestimate",
+ "zeversolar",
+ "zha",
+ "zhong_hong",
+ "ziggo_mediabox_xl",
+ "zodiac",
+ "zoneminder",
+ "zwave_js",
+ "zwave_me",
+]
+
+INTEGRATIONS_WITHOUT_SCALE = [
+ "abode",
+ "accuweather",
+ "acer_projector",
+ "acmeda",
+ "actiontec",
+ "adax",
+ "adguard",
+ "ads",
+ "advantage_air",
+ "aemet",
+ "aftership",
+ "agent_dvr",
+ "airly",
+ "airgradient",
+ "airnow",
+ "airq",
+ "airthings",
+ "airthings_ble",
+ "airtouch4",
+ "airtouch5",
+ "airvisual",
+ "airvisual_pro",
+ "airzone",
+ "airzone_cloud",
+ "aladdin_connect",
+ "alarmdecoder",
+ "alert",
+ "alexa",
+ "alpha_vantage",
+ "amazon_polly",
+ "amberelectric",
+ "ambient_network",
+ "ambient_station",
+ "amcrest",
+ "ampio",
+ "analytics",
+ "analytics_insights",
+ "android_ip_webcam",
+ "androidtv",
+ "androidtv_remote",
+ "anel_pwrctrl",
+ "anova",
+ "anthemav",
+ "anthropic",
+ "aosmith",
+ "apache_kafka",
+ "apcupsd",
+ "apple_tv",
+ "apprise",
+ "aprilaire",
+ "aprs",
+ "apsystems",
+ "aquacell",
+ "aqualogic",
+ "aquostv",
+ "aranet",
+ "arcam_fmj",
+ "arest",
+ "arris_tg2492lg",
+ "aruba",
+ "arve",
+ "arwn",
+ "aseko_pool_live",
+ "assist_pipeline",
+ "asterisk_mbox",
+ "asuswrt",
+ "atag",
+ "aten_pe",
+ "atome",
+ "august",
+ "autarco",
+ "aurora",
+ "aurora_abb_powerone",
+ "aussie_broadband",
+ "avea",
+ "avion",
+ "awair",
+ "aws",
+ "axis",
+ "azure_data_explorer",
+ "azure_devops",
+ "azure_event_hub",
+ "azure_service_bus",
+ "backup",
+ "baf",
+ "baidu",
+ "balboa",
+ "bang_olufsen",
+ "bayesian",
+ "bbox",
+ "beewi_smartclim",
+ "bitcoin",
+ "bizkaibus",
+ "blackbird",
+ "blebox",
+ "blink",
+ "blinksticklight",
+ "blockchain",
+ "blue_current",
+ "bluemaestro",
+ "bluesound",
+ "bluetooth",
+ "bluetooth_adapters",
+ "bluetooth_le_tracker",
+ "bluetooth_tracker",
+ "bmw_connected_drive",
+ "bond",
+ "bosch_shc",
+ "braviatv",
+ "broadlink",
+ "brother",
+ "brottsplatskartan",
+ "browser",
+ "brunt",
+ "bring",
+ "bryant_evolution",
+ "bsblan",
+ "bt_home_hub_5",
+ "bt_smarthub",
+ "bthome",
+ "buienradar",
+ "caldav",
+ "canary",
+ "cast",
+ "ccm15",
+ "cert_expiry",
+ "chacon_dio",
+ "channels",
+ "circuit",
+ "cisco_ios",
+ "cisco_mobility_express",
+ "cisco_webex_teams",
+ "citybikes",
+ "clementine",
+ "clickatell",
+ "clicksend",
+ "clicksend_tts",
+ "climacell",
+ "cloud",
+ "cloudflare",
+ "cmus",
+ "co2signal",
+ "coinbase",
+ "color_extractor",
+ "comed_hourly_pricing",
+ "comelit",
+ "comfoconnect",
+ "command_line",
+ "compensation",
+ "concord232",
+ "control4",
+ "coolmaster",
+ "cppm_tracker",
+ "cpuspeed",
+ "crownstone",
+ "cups",
+ "currencylayer",
+ "daikin",
+ "danfoss_air",
+ "datadog",
+ "ddwrt",
+ "deako",
+ "debugpy",
+ "deconz",
+ "decora",
+ "decora_wifi",
+ "delijn",
+ "deluge",
+ "demo",
+ "denon",
+ "denonavr",
+ "derivative",
+ "devialet",
+ "device_sun_light_trigger",
+ "devolo_home_control",
+ "devolo_home_network",
+ "dexcom",
+ "dhcp",
+ "dialogflow",
+ "digital_ocean",
+ "directv",
+ "discogs",
+ "discord",
+ "dlib_face_detect",
+ "dlib_face_identify",
+ "dlink",
+ "dlna_dmr",
+ "dlna_dms",
+ "dnsip",
+ "dominos",
+ "doods",
+ "doorbird",
+ "dormakaba_dkey",
+ "dovado",
+ "downloader",
+ "dremel_3d_printer",
+ "drop_connect",
+ "dsmr",
+ "dsmr_reader",
+ "dublin_bus_transport",
+ "duckdns",
+ "duke_energy",
+ "dunehd",
+ "duotecno",
+ "dwd_weather_warnings",
+ "dweet",
+ "dynalite",
+ "eafm",
+ "easyenergy",
+ "ebox",
+ "ebusd",
+ "ecoal_boiler",
+ "ecobee",
+ "ecoforest",
+ "econet",
+ "ecovacs",
+ "ecowitt",
+ "eddystone_temperature",
+ "edimax",
+ "edl21",
+ "efergy",
+ "egardia",
+ "eight_sleep",
+ "electrasmart",
+ "electric_kiwi",
+ "elevenlabs",
+ "eliqonline",
+ "elkm1",
+ "elmax",
+ "elgato",
+ "elv",
+ "elvia",
+ "emby",
+ "emoncms",
+ "emoncms_history",
+ "emonitor",
+ "emulated_hue",
+ "emulated_kasa",
+ "emulated_roku",
+ "energenie_power_sockets",
+ "energy",
+ "energyzero",
+ "enigma2",
+ "enphase_envoy",
+ "enocean",
+ "entur_public_transport",
+ "environment_canada",
+ "envisalink",
+ "ephember",
+ "epic_games_store",
+ "epion",
+ "epson",
+ "eq3btsmart",
+ "escea",
+ "esphome",
+ "etherscan",
+ "eufy",
+ "eufylife_ble",
+ "everlights",
+ "evil_genius_labs",
+ "evohome",
+ "ezviz",
+ "faa_delays",
+ "facebook",
+ "fail2ban",
+ "familyhub",
+ "fastdotcom",
+ "feedreader",
+ "ffmpeg_motion",
+ "ffmpeg_noise",
+ "fibaro",
+ "fido",
+ "file",
+ "filesize",
+ "filter",
+ "fitbit",
+ "fints",
+ "fireservicerota",
+ "firmata",
+ "fivem",
+ "fixer",
+ "fjaraskupan",
+ "fleetgo",
+ "flexit",
+ "flexit_bacnet",
+ "flic",
+ "flick_electric",
+ "flipr",
+ "flo",
+ "flock",
+ "flume",
+ "flux",
+ "flux_led",
+ "folder",
+ "folder_watcher",
+ "foobot",
+ "forecast_solar",
+ "forked_daapd",
+ "fortios",
+ "foscam",
+ "foursquare",
+ "free_mobile",
+ "freebox",
+ "freedns",
+ "freedompro",
+ "fritz",
+ "fritzbox",
+ "fritzbox_callmonitor",
+ "frontier_silicon",
+ "fujitsu_fglair",
+ "fujitsu_hvac",
+ "futurenow",
+ "garadget",
+ "garages_amsterdam",
+ "gardena_bluetooth",
+ "gc100",
+ "gdacs",
+ "generic",
+ "generic_hygrostat",
+ "generic_thermostat",
+ "geniushub",
+ "geo_json_events",
+ "geo_rss_events",
+ "geocaching",
+ "geofency",
+ "geonetnz_quakes",
+ "geonetnz_volcano",
+ "gios",
+ "github",
+ "gitlab_ci",
+ "gitter",
+ "glances",
+ "go2rtc",
+ "goalzero",
+ "gogogate2",
+ "goodwe",
+ "google",
+ "google_assistant",
+ "google_assistant_sdk",
+ "google_cloud",
+ "google_domains",
+ "google_generative_ai_conversation",
+ "google_mail",
+ "google_maps",
+ "google_photos",
+ "google_pubsub",
+ "google_sheets",
+ "google_tasks",
+ "google_translate",
+ "google_travel_time",
+ "google_wifi",
+ "govee_ble",
+ "govee_light_local",
+ "gpsd",
+ "gpslogger",
+ "graphite",
+ "gree",
+ "greeneye_monitor",
+ "greenwave",
+ "group",
+ "growatt_server",
+ "gstreamer",
+ "gtfs",
+ "guardian",
+ "habitica",
+ "harman_kardon_avr",
+ "harmony",
+ "hassio",
+ "haveibeenpwned",
+ "hddtemp",
+ "hdmi_cec",
+ "heatmiser",
+ "here_travel_time",
+ "hikvision",
+ "hikvisioncam",
+ "hisense_aehw4a1",
+ "history_stats",
+ "hitron_coda",
+ "hive",
+ "hko",
+ "hlk_sw16",
+ "holiday",
+ "home_connect",
+ "homekit",
+ "homekit_controller",
+ "homematic",
+ "homematicip_cloud",
+ "homeworks",
+ "honeywell",
+ "horizon",
+ "hp_ilo",
+ "html5",
+ "http",
+ "huawei_lte",
+ "hue",
+ "huisbaasje",
+ "hunterdouglas_powerview",
+ "husqvarna_automower_ble",
+ "huum",
+ "hvv_departures",
+ "hydrawise",
+ "hyperion",
+ "ialarm",
+ "iammeter",
+ "iaqualink",
+ "ibeacon",
+ "icloud",
+ "idteck_prox",
+ "ifttt",
+ "iglo",
+ "ign_sismologia",
+ "ihc",
+ "imap",
+ "imgw_pib",
+ "improv_ble",
+ "incomfort",
+ "influxdb",
+ "inkbird",
+ "insteon",
+ "integration",
+ "intellifire",
+ "intesishome",
+ "ios",
+ "iron_os",
+ "iotawatt",
+ "iotty",
+ "iperf3",
+ "ipma",
+ "ipp",
+ "iqvia",
+ "irish_rail_transport",
+ "isal",
+ "ista_ecotrend",
+ "iskra",
+ "islamic_prayer_times",
+ "israel_rail",
+ "iss",
+ "isy994",
+ "itach",
+ "itunes",
+ "izone",
+ "jellyfin",
+ "jewish_calendar",
+ "joaoapps_join",
+ "juicenet",
+ "justnimbus",
+ "jvc_projector",
+ "kaiterra",
+ "kaleidescape",
+ "kankun",
+ "keba",
+ "keenetic_ndms2",
+ "kef",
+ "kegtron",
+ "keyboard",
+ "keyboard_remote",
+ "keymitt_ble",
+ "kira",
+ "kitchen_sink",
+ "kiwi",
+ "kmtronic",
+ "knocki",
+ "kodi",
+ "konnected",
+ "kostal_plenticore",
+ "kraken",
+ "knx",
+ "kulersky",
+ "kwb",
+ "lacrosse",
+ "lacrosse_view",
+ "landisgyr_heat_meter",
+ "lannouncer",
+ "lastfm",
+ "lametric",
+ "launch_library",
+ "laundrify",
+ "lcn",
+ "ld2410_ble",
+ "leaone",
+ "led_ble",
+ "lektrico",
+ "lg_netcast",
+ "lg_soundbar",
+ "lg_thinq",
+ "lidarr",
+ "life360",
+ "lifx",
+ "lifx_cloud",
+ "lightwave",
+ "limitlessled",
+ "linear_garage_door",
+ "linkplay",
+ "linksys_smart",
+ "linode",
+ "linux_battery",
+ "lirc",
+ "litejet",
+ "livisi",
+ "llamalab_automate",
+ "local_calendar",
+ "local_file",
+ "local_ip",
+ "local_todo",
+ "location",
+ "locative",
+ "logentries",
+ "logi_circle",
+ "london_air",
+ "london_underground",
+ "lookin",
+ "loqed",
+ "luci",
+ "luftdaten",
+ "lupusec",
+ "lutron",
+ "lutron_caseta",
+ "lw12wifi",
+ "lyric",
+ "madvr",
+ "mailbox",
+ "mailgun",
+ "manual",
+ "manual_mqtt",
+ "map",
+ "mastodon",
+ "marytts",
+ "matrix",
+ "matter",
+ "maxcube",
+ "mazda",
+ "mealie",
+ "meater",
+ "medcom_ble",
+ "media_extractor",
+ "mediaroom",
+ "melcloud",
+ "melissa",
+ "melnor",
+ "meraki",
+ "message_bird",
+ "met",
+ "met_eireann",
+ "meteo_france",
+ "meteoalarm",
+ "meteoclimatic",
+ "metoffice",
+ "mfi",
+ "microbees",
+ "microsoft",
+ "microsoft_face",
+ "microsoft_face_detect",
+ "microsoft_face_identify",
+ "mikrotik",
+ "mill",
+ "min_max",
+ "minecraft_server",
+ "minio",
+ "mjpeg",
+ "moat",
+ "mobile_app",
+ "mochad",
+ "modbus",
+ "modem_callerid",
+ "modern_forms",
+ "moehlenhoff_alpha2",
+ "mold_indicator",
+ "monarch_money",
+ "monoprice",
+ "monzo",
+ "moon",
+ "mopeka",
+ "motion_blinds",
+ "motionblinds_ble",
+ "motioneye",
+ "motionmount",
+ "mpd",
+ "mqtt_eventstream",
+ "mqtt_json",
+ "mqtt_room",
+ "mqtt_statestream",
+ "msteams",
+ "mullvad",
+ "music_assistant",
+ "mutesync",
+ "mvglive",
+ "mycroft",
+ "myq",
+ "mysensors",
+ "mystrom",
+ "mythicbeastsdns",
+ "nad",
+ "nam",
+ "namecheapdns",
+ "nanoleaf",
+ "nasweb",
+ "neato",
+ "nederlandse_spoorwegen",
+ "nest",
+ "ness_alarm",
+ "netatmo",
+ "netdata",
+ "netgear",
+ "netgear_lte",
+ "netio",
+ "network",
+ "neurio_energy",
+ "nexia",
+ "nextbus",
+ "nextcloud",
+ "nextdns",
+ "nyt_games",
+ "nfandroidtv",
+ "nibe_heatpump",
+ "nice_go",
+ "nightscout",
+ "niko_home_control",
+ "nilu",
+ "nina",
+ "nissan_leaf",
+ "nmap_tracker",
+ "nmbs",
+ "no_ip",
+ "noaa_tides",
+ "nobo_hub",
+ "norway_air",
+ "notify_events",
+ "notion",
+ "nsw_fuel_station",
+ "nsw_rural_fire_service_feed",
+ "nuheat",
+ "nuki",
+ "numato",
+ "nut",
+ "nws",
+ "nx584",
+ "nzbget",
+ "oasa_telematics",
+ "obihai",
+ "octoprint",
+ "oem",
+ "ohmconnect",
+ "ollama",
+ "ombi",
+ "omnilogic",
+ "oncue",
+ "onkyo",
+ "ondilo_ico",
+ "onewire",
+ "onvif",
+ "open_meteo",
+ "openai_conversation",
+ "openalpr_cloud",
+ "openerz",
+ "openevse",
+ "openexchangerates",
+ "opengarage",
+ "openhardwaremonitor",
+ "openhome",
+ "opensensemap",
+ "opensky",
+ "opentherm_gw",
+ "openuv",
+ "openweathermap",
+ "opnsense",
+ "opower",
+ "opple",
+ "oralb",
+ "oru",
+ "orvibo",
+ "osoenergy",
+ "osramlightify",
+ "otbr",
+ "otp",
+ "ourgroceries",
+ "overkiz",
+ "ovo_energy",
+ "owntracks",
+ "p1_monitor",
+ "panasonic_bluray",
+ "panasonic_viera",
+ "pandora",
+ "palazzetti",
+ "panel_iframe",
+ "peco",
+ "pegel_online",
+ "pencom",
+ "permobil",
+ "persistent_notification",
+ "person",
+ "philips_js",
+ "pi_hole",
+ "picnic",
+ "picotts",
+ "pilight",
+ "ping",
+ "pioneer",
+ "pjlink",
+ "plaato",
+ "plant",
+ "plex",
+ "plum_lightpad",
+ "pocketcasts",
+ "point",
+ "poolsense",
+ "powerwall",
+ "private_ble_device",
+ "profiler",
+ "progettihwsw",
+ "proliphix",
+ "prometheus",
+ "prosegur",
+ "prowl",
+ "proximity",
+ "proxmoxve",
+ "prusalink",
+ "ps4",
+ "pulseaudio_loopback",
+ "pure_energie",
+ "purpleair",
+ "push",
+ "pushbullet",
+ "pushover",
+ "pushsafer",
+ "pvoutput",
+ "pvpc_hourly_pricing",
+ "pyload",
+ "qbittorrent",
+ "qingping",
+ "qld_bushfire",
+ "qnap",
+ "qnap_qsw",
+ "qrcode",
+ "quantum_gateway",
+ "qvr_pro",
+ "qwikswitch",
+ "rainbird",
+ "rabbitair",
+ "rachio",
+ "radarr",
+ "radio_browser",
+ "radiotherm",
+ "raincloud",
+ "rainforest_eagle",
+ "rainforest_raven",
+ "rainmachine",
+ "random",
+ "rapt_ble",
+ "raspyrfm",
+ "rdw",
+ "recollect_waste",
+ "recorder",
+ "recswitch",
+ "reddit",
+ "refoss",
+ "rejseplanen",
+ "remember_the_milk",
+ "remote_rpi_gpio",
+ "renson",
+ "repetier",
+ "rest",
+ "rest_command",
+ "rflink",
+ "rfxtrx",
+ "rhasspy",
+ "ridwell",
+ "ring",
+ "ripple",
+ "risco",
+ "rituals_perfume_genie",
+ "rmvtransport",
+ "roborock",
+ "rocketchat",
+ "roku",
+ "romy",
+ "roomba",
+ "roon",
+ "route53",
+ "rova",
+ "rpi_camera",
+ "rpi_power",
+ "rss_feed_template",
+ "rtorrent",
+ "rtsp_to_webrtc",
+ "ruckus_unleashed",
"russound_rnet",
"ruuvi_gateway",
"ruuvitag_ble",
@@ -975,12 +2055,14 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"stiebel_eltron",
"stream",
"streamlabswater",
+ "stookwijzer",
"subaru",
"sun",
"sunweg",
"supervisord",
"supla",
"surepetcare",
+ "swiss_public_transport",
"swiss_hydrological_data",
"swisscom",
"switch_as_x",
@@ -999,6 +2081,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"systemmonitor",
"tado",
"tailscale",
+ "tailwind",
"tami4",
"tank_utility",
"tankerkoenig",
@@ -1042,10 +2125,10 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"tomato",
"tomorrowio",
"toon",
+ "totalconnect",
"torque",
"touchline",
"touchline_sl",
- "tplink",
"tplink_lte",
"tplink_omada",
"traccar",
@@ -1092,10 +2175,12 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"velux",
"venstar",
"vera",
+ "velbus",
"verisure",
"versasense",
"version",
"vesync",
+ "vicare",
"viaggiatreno",
"vilfo",
"vivotek",
@@ -1122,8 +2207,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"weatherflow",
"weatherflow_cloud",
"weatherkit",
- "webmin",
"webostv",
+ "webmin",
"weheat",
"wemo",
"whirlpool",
@@ -1311,7 +2396,27 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE:
integration.add_error(
"quality_scale",
- "Quality scale file found! Please remove from script/hassfest/quality_scale.py",
+ "Quality scale file found! Please remove from `INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE`"
+ " in script/hassfest/quality_scale.py",
+ )
+ return
+ if (
+ integration.domain in INTEGRATIONS_WITHOUT_SCALE
+ and declared_quality_scale is not None
+ ):
+ integration.add_error(
+ "quality_scale",
+ "This integration is graded and should be removed from `INTEGRATIONS_WITHOUT_SCALE`"
+ " in script/hassfest/quality_scale.py",
+ )
+ return
+ if (
+ integration.domain not in INTEGRATIONS_WITHOUT_SCALE
+ and declared_quality_scale is None
+ ):
+ integration.add_error(
+ "quality_scale",
+ "New integrations are required to at least reach the Bronze tier.",
)
return
name = str(iqs_file)
diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py
index d11bcaf2cec..45eaafde0b5 100644
--- a/script/hassfest/quality_scale_validation/discovery.py
+++ b/script/hassfest/quality_scale_validation/discovery.py
@@ -55,8 +55,7 @@ def validate(
config_flow = ast_parse_module(config_flow_file)
if not (_has_discovery_function(config_flow)):
return [
- f"Integration is missing one of {CONFIG_FLOW_STEPS} "
- f"in {config_flow_file}"
+ f"Integration is missing one of {CONFIG_FLOW_STEPS} in {config_flow_file}"
]
return None
diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py
index c1373032ff8..1f5a5665835 100644
--- a/script/hassfest/quality_scale_validation/strict_typing.py
+++ b/script/hassfest/quality_scale_validation/strict_typing.py
@@ -43,7 +43,11 @@ def _check_requirements_are_typed(integration: Integration) -> list[str]:
if not any(file for file in distribution.files if file.name == "py.typed"):
# no py.typed file
- invalid_requirements.append(requirement)
+ try:
+ metadata.distribution(f"types-{requirement_name}")
+ except metadata.PackageNotFoundError:
+ # also no stubs-only package
+ invalid_requirements.append(requirement)
return invalid_requirements
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index 2fb70b6e0be..b3d397dbd55 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -454,7 +454,7 @@ ONBOARDING_SCHEMA = vol.Schema(
)
-def validate_translation_file( # noqa: C901
+def validate_translation_file(
config: Config,
integration: Integration,
all_strings: dict[str, Any] | None,
@@ -510,8 +510,8 @@ def validate_translation_file( # noqa: C901
):
integration.add_error(
"translations",
- "Don't specify title in translation strings if it's a brand "
- "name or add exception to ALLOW_NAME_TRANSLATION",
+ "Don't specify title in translation strings if it's "
+ "a brand name or add exception to ALLOW_NAME_TRANSLATION",
)
if config.specific_integrations:
@@ -532,12 +532,15 @@ def validate_translation_file( # noqa: C901
if parts or key not in search:
integration.add_error(
"translations",
- f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}",
+ f"{reference['source']} contains invalid reference"
+ f"{reference['ref']}: Could not find {key}",
)
elif match := re.match(RE_REFERENCE, search[key]):
integration.add_error(
"translations",
- f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"",
+ "Lokalise supports only one level of references: "
+ f'"{reference["source"]}" should point to directly '
+ f'to "{match.groups()[0]}"',
)
diff --git a/script/ruff.toml b/script/ruff.toml
index c32b39022cc..a14712ec142 100644
--- a/script/ruff.toml
+++ b/script/ruff.toml
@@ -5,3 +5,7 @@ extend = "../pyproject.toml"
forced-separate = [
"tests",
]
+
+[lint.flake8-tidy-imports.banned-api]
+"async_timeout".msg = "use asyncio.timeout instead"
+"pytz".msg = "use zoneinfo instead"
diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py
index cfa2669ebfe..d90e01c3ebd 100644
--- a/script/scaffold/gather_info.py
+++ b/script/scaffold/gather_info.py
@@ -93,7 +93,7 @@ def gather_new_integration(determine_auth: bool) -> Info:
"prompt": (
f"""How will your integration gather data?
-Valid values are {', '.join(SUPPORTED_IOT_CLASSES)}
+Valid values are {", ".join(SUPPORTED_IOT_CLASSES)}
More info @ https://developers.home-assistant.io/docs/creating_integration_manifest#iot-class
"""
diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py
index 0b752e71013..11759c48cf3 100644
--- a/script/scaffold/templates/config_flow/integration/__init__.py
+++ b/script/scaffold/templates/config_flow/integration/__init__.py
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
# TODO List the platforms that you want to support.
# For your initial PR, limit it to 1 platform.
-PLATFORMS: list[Platform] = [Platform.LIGHT]
+_PLATFORMS: list[Platform] = [Platform.LIGHT]
# TODO Create ConfigEntry type alias with API object
# TODO Rename type alias and update all entry annotations
@@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) ->
# TODO 3. Store an API object for your platforms to access
# entry.runtime_data = MyAPI(...)
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
@@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) ->
# TODO Update entry annotation
async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool:
"""Unload a config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py
index 9a712834bae..66209f77e6a 100644
--- a/script/scaffold/templates/config_flow/tests/test_config_flow.py
+++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py
@@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
- assert result["type"] == FlowResultType.FORM
+ assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with patch(
@@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
)
await hass.async_block_till_done()
- assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Name of the device"
assert result["data"] == {
CONF_HOST: "1.1.1.1",
@@ -63,7 +63,7 @@ async def test_form_invalid_auth(
},
)
- assert result["type"] == FlowResultType.FORM
+ assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
# Make sure the config flow tests finish with either an
@@ -83,7 +83,7 @@ async def test_form_invalid_auth(
)
await hass.async_block_till_done()
- assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Name of the device"
assert result["data"] == {
CONF_HOST: "1.1.1.1",
@@ -114,7 +114,7 @@ async def test_form_cannot_connect(
},
)
- assert result["type"] == FlowResultType.FORM
+ assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Make sure the config flow tests finish with either an
@@ -135,7 +135,7 @@ async def test_form_cannot_connect(
)
await hass.async_block_till_done()
- assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Name of the device"
assert result["data"] == {
CONF_HOST: "1.1.1.1",
diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py
index 06b91f51949..ba56b958273 100644
--- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py
+++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
# TODO List the platforms that you want to support.
# For your initial PR, limit it to 1 platform.
-PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
+_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
# TODO Create ConfigEntry type alias with API object
# Alias name should be prefixed by integration name
@@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# TODO 3. Store an API object for your platforms to access
# entry.runtime_data = MyAPI(...)
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
@@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# TODO Update entry annotation
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
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 8e7854835d8..fbf705cfb26 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
@@ -24,7 +24,7 @@ async def test_config_flow(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
- assert result["type"] == FlowResultType.FORM
+ assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
result = await hass.config_entries.flow.async_configure(
@@ -33,7 +33,7 @@ async def test_config_flow(
)
await hass.async_block_till_done()
- assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "My NEW_DOMAIN"
assert result["data"] == {}
assert result["options"] == {
@@ -83,7 +83,7 @@ async def test_options(hass: HomeAssistant, platform) -> None:
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
- assert result["type"] == FlowResultType.FORM
+ assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
schema = result["data_schema"].schema
assert get_suggested(schema, "entity_id") == input_sensor_1_entity_id
@@ -94,7 +94,7 @@ async def test_options(hass: HomeAssistant, platform) -> None:
"entity_id": input_sensor_2_entity_id,
},
)
- assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"entity_id": input_sensor_2_entity_id,
"name": "My NEW_DOMAIN",
diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py
index b8403392471..8eaf8b0e25a 100644
--- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py
+++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py
@@ -11,7 +11,7 @@ from . import api
# TODO List the platforms that you want to support.
# For your initial PR, limit it to 1 platform.
-PLATFORMS: list[Platform] = [Platform.LIGHT]
+_PLATFORMS: list[Platform] = [Platform.LIGHT]
# TODO Create ConfigEntry type alias with ConfigEntryAuth or AsyncConfigEntryAuth object
# TODO Rename type alias and update all entry annotations
@@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) ->
aiohttp_client.async_get_clientsession(hass), session
)
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
@@ -45,4 +45,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) ->
# TODO Update entry annotation
async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool:
"""Unload a config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/script/split_tests.py b/script/split_tests.py
index c64de46a068..0018472e54e 100755
--- a/script/split_tests.py
+++ b/script/split_tests.py
@@ -79,7 +79,7 @@ class BucketHolder:
"""Create output file."""
with Path("pytest_buckets.txt").open("w") as file:
for idx, bucket in enumerate(self._buckets):
- print(f"Bucket {idx+1} has {bucket.total_tests} tests")
+ print(f"Bucket {idx + 1} has {bucket.total_tests} tests")
file.write(bucket.get_paths_line())
diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py
index f92f90115ce..ac608a1aa0e 100644
--- a/script/translations/deduplicate.py
+++ b/script/translations/deduplicate.py
@@ -70,8 +70,10 @@ def run():
# If we want to only add references to own integrations
# but not include entity integrations
if (
- args.limit_reference
- and (key_integration != key_to_reference_integration and not is_common)
+ (
+ args.limit_reference
+ and (key_integration != key_to_reference_integration and not is_common)
+ )
# Do not create self-references in entity integrations
or key_integration in Platform.__members__.values()
):
diff --git a/script/translations/develop.py b/script/translations/develop.py
index 9e3a2ded046..00ac7bf98ac 100644
--- a/script/translations/develop.py
+++ b/script/translations/develop.py
@@ -4,7 +4,6 @@ import argparse
import json
from pathlib import Path
import re
-from shutil import rmtree
import sys
from . import download, upload
@@ -83,9 +82,10 @@ def run_single(translations, flattened_translations, integration):
)
if download.DOWNLOAD_DIR.is_dir():
- rmtree(str(download.DOWNLOAD_DIR))
-
- download.DOWNLOAD_DIR.mkdir(parents=True)
+ for lang_file in download.DOWNLOAD_DIR.glob("*.json"):
+ lang_file.unlink()
+ else:
+ download.DOWNLOAD_DIR.mkdir(parents=True)
(download.DOWNLOAD_DIR / "en.json").write_text(
json.dumps({"component": {integration: translations["component"][integration]}})
diff --git a/tests/common.py b/tests/common.py
index ac6f10b8c44..0315ee6d845 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -15,7 +15,7 @@ from collections.abc import (
)
from contextlib import asynccontextmanager, contextmanager, suppress
from datetime import UTC, datetime, timedelta
-from enum import Enum
+from enum import Enum, StrEnum
import functools as ft
from functools import lru_cache
from io import StringIO
@@ -31,7 +31,6 @@ from unittest.mock import AsyncMock, Mock, patch
from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401
import pytest
from syrupy import SnapshotAssertion
-from typing_extensions import TypeVar
import voluptuous as vol
from homeassistant import auth, bootstrap, config_entries, loader
@@ -90,12 +89,12 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.util import dt as dt_util, ulid as ulid_util
from homeassistant.util.async_ import (
_SHUTDOWN_RUN_CALLBACK_THREADSAFE,
get_scheduled_timer_handles,
run_callback_threadsafe,
)
-import homeassistant.util.dt as dt_util
from homeassistant.util.event_type import EventType
from homeassistant.util.json import (
JsonArrayType,
@@ -106,22 +105,27 @@ from homeassistant.util.json import (
json_loads_object,
)
from homeassistant.util.signal_type import SignalType
-import homeassistant.util.ulid as ulid_util
from homeassistant.util.unit_system import METRIC_SYSTEM
-import homeassistant.util.yaml.loader as yaml_loader
+from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader
from .testing_config.custom_components.test_constant_deprecation import (
import_deprecated_constant,
)
-_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=dict[str, Any])
-
_LOGGER = logging.getLogger(__name__)
INSTANCES = []
CLIENT_ID = "https://example.com/app"
CLIENT_REDIRECT_URI = "https://example.com/app/callback"
+class QualityScaleStatus(StrEnum):
+ """Source of core configuration."""
+
+ DONE = "done"
+ EXEMPT = "exempt"
+ TODO = "todo"
+
+
async def async_get_device_automations(
hass: HomeAssistant,
automation_type: device_automation.DeviceAutomationType,
@@ -1189,16 +1193,16 @@ def assert_setup_component(count, domain=None):
yield config
if domain is None:
- assert (
- len(config) == 1
- ), f"assert_setup_component requires DOMAIN: {list(config.keys())}"
+ assert len(config) == 1, (
+ f"assert_setup_component requires DOMAIN: {list(config.keys())}"
+ )
domain = list(config.keys())[0]
res = config.get(domain)
res_len = 0 if res is None else len(res)
- assert (
- res_len == count
- ), f"setup_component failed, expected {count} got {res_len}: {res}"
+ assert res_len == count, (
+ f"setup_component failed, expected {count} got {res_len}: {res}"
+ )
def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None:
@@ -1537,7 +1541,7 @@ def mock_platform(
module_cache[platform_path] = module or Mock()
-def async_capture_events(
+def async_capture_events[_DataT: Mapping[str, Any] = dict[str, Any]](
hass: HomeAssistant, event_name: EventType[_DataT] | str
) -> list[Event[_DataT]]:
"""Create a helper that captures events."""
@@ -1806,9 +1810,9 @@ async def snapshot_platform(
"""Snapshot a platform."""
entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id)
assert entity_entries
- assert (
- len({entity_entry.domain for entity_entry in entity_entries}) == 1
- ), "Please limit the loaded platforms to 1 platform."
+ assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, (
+ "Please limit the loaded platforms to 1 platform."
+ )
for entity_entry in entity_entries:
assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
assert entity_entry.disabled_by is None, "Please enable all entities."
@@ -1832,3 +1836,22 @@ def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None:
for loaded_components in loaded_categories.values():
for component_to_unload in components:
loaded_components.pop(component_to_unload, None)
+
+
+@lru_cache
+def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]:
+ """Load quality scale for integration."""
+ quality_scale_file = pathlib.Path(
+ f"homeassistant/components/{integration}/quality_scale.yaml"
+ )
+ if not quality_scale_file.exists():
+ return {}
+ raw = load_yaml_dict(quality_scale_file)
+ return {
+ rule: (
+ QualityScaleStatus(details)
+ if isinstance(details, str)
+ else QualityScaleStatus(details["status"])
+ )
+ for rule, details in raw["rules"].items()
+ }
diff --git a/tests/components/acmeda/conftest.py b/tests/components/acmeda/conftest.py
index 2c980351c09..4a803711959 100644
--- a/tests/components/acmeda/conftest.py
+++ b/tests/components/acmeda/conftest.py
@@ -1,5 +1,8 @@
"""Define fixtures available for all Acmeda tests."""
+from collections.abc import Generator
+from unittest.mock import AsyncMock, patch
+
import pytest
from homeassistant.components.acmeda.const import DOMAIN
@@ -18,3 +21,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
)
mock_config_entry.add_to_hass(hass)
return mock_config_entry
+
+
+@pytest.fixture
+def mock_hub_run() -> Generator[AsyncMock]:
+ """Mock the hub run method."""
+ with patch("homeassistant.components.acmeda.hub.aiopulse.Hub.run") as mock_run:
+ yield mock_run
diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py
index 5227d283f25..7b92c1aac3b 100644
--- a/tests/components/acmeda/test_config_flow.py
+++ b/tests/components/acmeda/test_config_flow.py
@@ -28,13 +28,6 @@ def mock_hub_discover():
yield mock_discover
-@pytest.fixture
-def mock_hub_run():
- """Mock the hub run method."""
- with patch("aiopulse.Hub.run") as mock_run:
- yield mock_run
-
-
async def async_generator(items):
"""Async yields items provided in a list."""
for item in items:
@@ -56,9 +49,8 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None
assert len(mock_hub_discover.mock_calls) == 1
-async def test_show_form_one_hub(
- hass: HomeAssistant, mock_hub_discover, mock_hub_run
-) -> None:
+@pytest.mark.usefixtures("mock_hub_run")
+async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None:
"""Test that a config is created when one hub discovered."""
dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1)
@@ -102,9 +94,8 @@ async def test_show_form_two_hubs(hass: HomeAssistant, mock_hub_discover) -> Non
assert len(mock_hub_discover.mock_calls) == 1
-async def test_create_second_entry(
- hass: HomeAssistant, mock_hub_run, mock_hub_discover
-) -> None:
+@pytest.mark.usefixtures("mock_hub_run")
+async def test_create_second_entry(hass: HomeAssistant, mock_hub_discover) -> None:
"""Test that a config is created when a second hub is discovered."""
dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1)
diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py
index 0d908ecc915..d5b6997ee33 100644
--- a/tests/components/acmeda/test_cover.py
+++ b/tests/components/acmeda/test_cover.py
@@ -1,5 +1,7 @@
"""Define tests for the Acmeda config flow."""
+import pytest
+
from homeassistant.components.acmeda.const import DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.core import HomeAssistant
@@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
+@pytest.mark.usefixtures("mock_hub_run")
async def test_cover_id_migration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py
index 3d7090ce7dd..12195d3aec4 100644
--- a/tests/components/acmeda/test_sensor.py
+++ b/tests/components/acmeda/test_sensor.py
@@ -1,5 +1,7 @@
"""Define tests for the Acmeda config flow."""
+import pytest
+
from homeassistant.components.acmeda.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
@@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
+@pytest.mark.usefixtures("mock_hub_run")
async def test_sensor_id_migration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py
index d0f577c8068..d4fca62e98b 100644
--- a/tests/components/aemet/test_sensor.py
+++ b/tests/components/aemet/test_sensor.py
@@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.weather import ATTR_CONDITION_SNOWY
from homeassistant.core import HomeAssistant
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .util import async_init_integration
diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr
index 941369ff266..3db188bed95 100644
--- a/tests/components/airgradient/snapshots/test_sensor.ambr
+++ b/tests/components/airgradient/snapshots/test_sensor.ambr
@@ -763,6 +763,57 @@
'state': '16931',
})
# ---
+# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-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.airgradient_raw_pm2_5',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Raw PM2.5',
+ 'platform': 'airgradient',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'raw_pm02',
+ 'unique_id': '84fce612f5b8-pm02_raw',
+ 'unit_of_measurement': 'µg/m³',
+ })
+# ---
+# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'pm25',
+ 'friendly_name': 'Airgradient Raw PM2.5',
+ 'state_class': ,
+ 'unit_of_measurement': 'µg/m³',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.airgradient_raw_pm2_5',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '34',
+ })
+# ---
# name: test_all_entities[indoor][sensor.airgradient_raw_voc-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py
index 83de2c2f048..2440669b6e8 100644
--- a/tests/components/airgradient/test_button.py
+++ b/tests/components/airgradient/test_button.py
@@ -3,14 +3,16 @@
from datetime import timedelta
from unittest.mock import AsyncMock, patch
-from airgradient import Config
+from airgradient import AirGradientConnectionError, AirGradientError, Config
from freezegun.api import FrozenDateTimeFactory
+import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient.const import DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@@ -97,3 +99,37 @@ async def test_cloud_creates_no_button(
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
+
+
+@pytest.mark.parametrize(
+ ("exception", "error_message"),
+ [
+ (
+ AirGradientConnectionError("Something happened"),
+ "An error occurred while communicating with the Airgradient device: Something happened",
+ ),
+ (
+ AirGradientError("Something else happened"),
+ "An unknown error occurred while communicating with the Airgradient device: Something else happened",
+ ),
+ ],
+)
+async def test_exception_handling(
+ hass: HomeAssistant,
+ mock_airgradient_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ exception: Exception,
+ error_message: str,
+) -> None:
+ """Test exception handling."""
+ await setup_integration(hass, mock_config_entry)
+ mock_airgradient_client.request_co2_calibration.side_effect = exception
+ with pytest.raises(HomeAssistantError, match=error_message):
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {
+ ATTR_ENTITY_ID: "button.airgradient_calibrate_co2_sensor",
+ },
+ blocking=True,
+ )
diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py
index 8927947c40e..4c035b09aa7 100644
--- a/tests/components/airgradient/test_config_flow.py
+++ b/tests/components/airgradient/test_config_flow.py
@@ -10,11 +10,11 @@ from airgradient import (
)
from homeassistant.components.airgradient.const import DOMAIN
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
@@ -296,3 +296,99 @@ async def test_user_flow_works_discovery(
# Verify the discovery flow was aborted
assert not hass.config_entries.flow.async_progress(DOMAIN)
+
+
+async def test_reconfigure_flow(
+ hass: HomeAssistant,
+ mock_new_airgradient_client: AsyncMock,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reconfigure flow."""
+ mock_config_entry.add_to_hass(hass)
+
+ result = await mock_config_entry.start_reconfigure_flow(hass)
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "10.0.0.131"},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reconfigure_successful"
+ assert mock_config_entry.data == {
+ CONF_HOST: "10.0.0.131",
+ }
+
+
+async def test_reconfigure_flow_errors(
+ hass: HomeAssistant,
+ mock_new_airgradient_client: AsyncMock,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reconfigure flow."""
+ mock_config_entry.add_to_hass(hass)
+ mock_new_airgradient_client.get_current_measures.side_effect = (
+ AirGradientConnectionError()
+ )
+
+ result = await mock_config_entry.start_reconfigure_flow(hass)
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "10.0.0.132"},
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ mock_new_airgradient_client.get_current_measures.side_effect = None
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "10.0.0.132"},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reconfigure_successful"
+ assert mock_config_entry.data == {
+ CONF_HOST: "10.0.0.132",
+ }
+
+
+async def test_reconfigure_flow_unique_id_mismatch(
+ hass: HomeAssistant,
+ mock_new_airgradient_client: AsyncMock,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reconfigure flow aborts with unique id mismatch."""
+ mock_config_entry.add_to_hass(hass)
+
+ mock_new_airgradient_client.get_current_measures.return_value.serial_number = (
+ "84fce612f5b9"
+ )
+
+ result = await mock_config_entry.start_reconfigure_flow(hass)
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "10.0.0.132"},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "unique_id_mismatch"
+ assert mock_config_entry.data == {
+ CONF_HOST: "10.0.0.131",
+ }
diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py
index 7aabda8f81c..2cbd72d033a 100644
--- a/tests/components/airgradient/test_number.py
+++ b/tests/components/airgradient/test_number.py
@@ -3,8 +3,9 @@
from datetime import timedelta
from unittest.mock import AsyncMock, patch
-from airgradient import Config
+from airgradient import AirGradientConnectionError, AirGradientError, Config
from freezegun.api import FrozenDateTimeFactory
+import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient.const import DOMAIN
@@ -15,6 +16,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@@ -99,3 +101,37 @@ async def test_cloud_creates_no_number(
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
+
+
+@pytest.mark.parametrize(
+ ("exception", "error_message"),
+ [
+ (
+ AirGradientConnectionError("Something happened"),
+ "An error occurred while communicating with the Airgradient device: Something happened",
+ ),
+ (
+ AirGradientError("Something else happened"),
+ "An unknown error occurred while communicating with the Airgradient device: Something else happened",
+ ),
+ ],
+)
+async def test_exception_handling(
+ hass: HomeAssistant,
+ mock_airgradient_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ exception: Exception,
+ error_message: str,
+) -> None:
+ """Test exception handling."""
+ await setup_integration(hass, mock_config_entry)
+
+ mock_airgradient_client.set_display_brightness.side_effect = exception
+ with pytest.raises(HomeAssistantError, match=error_message):
+ await hass.services.async_call(
+ NUMBER_DOMAIN,
+ SERVICE_SET_VALUE,
+ service_data={ATTR_VALUE: 50},
+ target={ATTR_ENTITY_ID: "number.airgradient_display_brightness"},
+ blocking=True,
+ )
diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py
index de4a7beaaa7..b8ae2cefa4e 100644
--- a/tests/components/airgradient/test_select.py
+++ b/tests/components/airgradient/test_select.py
@@ -3,7 +3,7 @@
from datetime import timedelta
from unittest.mock import AsyncMock, patch
-from airgradient import Config
+from airgradient import AirGradientConnectionError, AirGradientError, Config
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
@@ -15,6 +15,7 @@ from homeassistant.components.select import (
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@@ -94,3 +95,39 @@ async def test_cloud_creates_no_number(
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
+
+
+@pytest.mark.parametrize(
+ ("exception", "error_message"),
+ [
+ (
+ AirGradientConnectionError("Something happened"),
+ "An error occurred while communicating with the Airgradient device: Something happened",
+ ),
+ (
+ AirGradientError("Something else happened"),
+ "An unknown error occurred while communicating with the Airgradient device: Something else happened",
+ ),
+ ],
+)
+async def test_exception_handling(
+ hass: HomeAssistant,
+ mock_airgradient_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ exception: Exception,
+ error_message: str,
+) -> None:
+ """Test exception handling."""
+ await setup_integration(hass, mock_config_entry)
+
+ mock_airgradient_client.set_configuration_control.side_effect = exception
+ with pytest.raises(HomeAssistantError, match=error_message):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: "select.airgradient_configuration_source",
+ ATTR_OPTION: "local",
+ },
+ blocking=True,
+ )
diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py
index a0cbdd17d75..475f38f554c 100644
--- a/tests/components/airgradient/test_switch.py
+++ b/tests/components/airgradient/test_switch.py
@@ -3,8 +3,9 @@
from datetime import timedelta
from unittest.mock import AsyncMock, patch
-from airgradient import Config
+from airgradient import AirGradientConnectionError, AirGradientError, Config
from freezegun.api import FrozenDateTimeFactory
+import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient.const import DOMAIN
@@ -16,6 +17,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@@ -99,3 +101,36 @@ async def test_cloud_creates_no_switch(
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
+
+
+@pytest.mark.parametrize(
+ ("exception", "error_message"),
+ [
+ (
+ AirGradientConnectionError("Something happened"),
+ "An error occurred while communicating with the Airgradient device: Something happened",
+ ),
+ (
+ AirGradientError("Something else happened"),
+ "An unknown error occurred while communicating with the Airgradient device: Something else happened",
+ ),
+ ],
+)
+async def test_exception_handling(
+ hass: HomeAssistant,
+ mock_airgradient_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ exception: Exception,
+ error_message: str,
+) -> None:
+ """Test exception handling."""
+ await setup_integration(hass, mock_config_entry)
+
+ mock_airgradient_client.enable_sharing_data.side_effect = exception
+ with pytest.raises(HomeAssistantError, match=error_message):
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ target={ATTR_ENTITY_ID: "switch.airgradient_post_data_to_airgradient"},
+ blocking=True,
+ )
diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py
index 79ae46500dd..314594c612f 100644
--- a/tests/components/airthings_ble/test_config_flow.py
+++ b/tests/components/airthings_ble/test_config_flow.py
@@ -7,7 +7,7 @@ from bleak import BleakError
import pytest
from homeassistant.components.airthings_ble.const import DOMAIN
-from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
+from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -153,6 +153,57 @@ async def test_user_setup(hass: HomeAssistant) -> None:
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
+async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
+ """Test the user initiated form can replace an ignored device."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="cc:cc:cc:cc:cc:cc",
+ source=SOURCE_IGNORE,
+ data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"},
+ )
+ entry.add_to_hass(hass)
+ with (
+ patch(
+ "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
+ return_value=[WAVE_SERVICE_INFO],
+ ),
+ patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
+ patch_airthings_ble(
+ AirthingsDevice(
+ manufacturer="Airthings AS",
+ model=AirthingsDeviceType.WAVE_PLUS,
+ name="Airthings Wave Plus",
+ identifier="123456",
+ )
+ ),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] is None
+ assert result["data_schema"] is not None
+ schema = result["data_schema"].schema
+
+ assert schema.get(CONF_ADDRESS).container == {
+ "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus"
+ }
+
+ with patch(
+ "homeassistant.components.airthings_ble.async_setup_entry",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}
+ )
+
+ await hass.async_block_till_done()
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "Airthings Wave Plus (123456)"
+ assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
+
+
async def test_user_setup_no_device(hass: HomeAssistant) -> None:
"""Test the user initiated form without any device detected."""
with patch(
diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr
index bb44a0abeb1..0c3c0ba7c7a 100644
--- a/tests/components/airzone/snapshots/test_diagnostics.ambr
+++ b/tests/components/airzone/snapshots/test_diagnostics.ambr
@@ -275,6 +275,7 @@
'config_entry': dict({
'data': dict({
'host': '192.168.1.100',
+ 'id': 0,
'port': 3000,
}),
'disabled_by': None,
@@ -282,7 +283,7 @@
}),
'domain': 'airzone',
'entry_id': '6e7a0798c1734ba81d26ced0e690eaec',
- 'minor_version': 1,
+ 'minor_version': 2,
'options': dict({
}),
'pref_disable_new_entities': False,
diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py
index 072699c7a26..65897c6da7e 100644
--- a/tests/components/airzone/test_config_flow.py
+++ b/tests/components/airzone/test_config_flow.py
@@ -12,7 +12,6 @@ from aioairzone.exceptions import (
)
from homeassistant import config_entries
-from homeassistant.components import dhcp
from homeassistant.components.airzone.config_flow import short_mac
from homeassistant.components.airzone.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
@@ -20,6 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .util import (
CONFIG,
@@ -28,11 +28,12 @@ from .util import (
HVAC_MOCK,
HVAC_VERSION_MOCK,
HVAC_WEBSERVER_MOCK,
+ USER_INPUT,
)
from tests.common import MockConfigEntry
-DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo(
+DHCP_SERVICE_INFO = DhcpServiceInfo(
hostname="airzone",
ip="192.168.1.100",
macaddress=dr.format_mac("E84F25000000").replace(":", ""),
@@ -81,7 +82,7 @@ async def test_form(hass: HomeAssistant) -> None:
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], CONFIG
+ result["flow_id"], USER_INPUT
)
await hass.async_block_till_done()
@@ -94,7 +95,7 @@ async def test_form(hass: HomeAssistant) -> None:
assert result["title"] == f"Airzone {CONFIG[CONF_HOST]}:{CONFIG[CONF_PORT]}"
assert result["data"][CONF_HOST] == CONFIG[CONF_HOST]
assert result["data"][CONF_PORT] == CONFIG[CONF_PORT]
- assert CONF_ID not in result["data"]
+ assert result["data"][CONF_ID] == CONFIG[CONF_ID]
assert len(mock_setup_entry.mock_calls) == 1
@@ -129,7 +130,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None:
),
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
)
assert result["type"] is FlowResultType.FORM
@@ -154,7 +155,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert (
result["title"]
- == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]}"
+ == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]} #{CONFIG_ID1[CONF_ID]}"
)
assert result["data"][CONF_HOST] == CONFIG_ID1[CONF_HOST]
assert result["data"][CONF_PORT] == CONFIG_ID1[CONF_PORT]
@@ -167,6 +168,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None:
"""Test setting up duplicated entry."""
config_entry = MockConfigEntry(
+ minor_version=2,
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_unique_id",
@@ -174,7 +176,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None:
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
)
assert result["type"] is FlowResultType.ABORT
@@ -189,7 +191,7 @@ async def test_connection_error(hass: HomeAssistant) -> None:
side_effect=AirzoneError,
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
)
assert result["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py
index 583758a6bee..fcdcad6a32a 100644
--- a/tests/components/airzone/test_coordinator.py
+++ b/tests/components/airzone/test_coordinator.py
@@ -25,6 +25,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
"""Test ClientConnectorError on coordinator update."""
config_entry = MockConfigEntry(
+ minor_version=2,
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_unique_id",
@@ -74,6 +75,7 @@ async def test_coordinator_new_devices(
"""Test new devices on coordinator update."""
config_entry = MockConfigEntry(
+ minor_version=2,
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_unique_id",
diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py
index 293fc75acb5..a2783cb7c2f 100644
--- a/tests/components/airzone/test_init.py
+++ b/tests/components/airzone/test_init.py
@@ -2,14 +2,16 @@
from unittest.mock import patch
+from aioairzone.const import DEFAULT_SYSTEM_ID
from aioairzone.exceptions import HotWaterNotAvailable, InvalidMethod, SystemOutOfRange
from homeassistant.components.airzone.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK
+from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, USER_INPUT
from tests.common import MockConfigEntry
@@ -19,7 +21,11 @@ async def test_unique_id_migrate(
) -> None:
"""Test unique id migration."""
- config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG)
+ config_entry = MockConfigEntry(
+ minor_version=2,
+ domain=DOMAIN,
+ data=CONFIG,
+ )
config_entry.add_to_hass(hass)
with (
@@ -89,6 +95,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test unload."""
config_entry = MockConfigEntry(
+ minor_version=2,
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_unique_id",
@@ -112,3 +119,42 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
+
+
+async def test_migrate_entry_v2(hass: HomeAssistant) -> None:
+ """Test entry migration to v2."""
+
+ config_entry = MockConfigEntry(
+ minor_version=1,
+ data=USER_INPUT,
+ domain=DOMAIN,
+ )
+ config_entry.add_to_hass(hass)
+
+ with (
+ patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_dhw",
+ side_effect=HotWaterNotAvailable,
+ ),
+ patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_hvac",
+ return_value=HVAC_MOCK,
+ ),
+ patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
+ side_effect=SystemOutOfRange,
+ ),
+ patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
+ ),
+ patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
+ side_effect=InvalidMethod,
+ ),
+ ):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.minor_version == 2
+ assert config_entry.data.get(CONF_ID) == DEFAULT_SYSTEM_ID
diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py
index b51dfb890e4..50d1964924d 100644
--- a/tests/components/airzone/util.py
+++ b/tests/components/airzone/util.py
@@ -55,6 +55,7 @@ from aioairzone.const import (
API_WS_AZ,
API_WS_TYPE,
API_ZONE_ID,
+ DEFAULT_SYSTEM_ID,
)
from homeassistant.components.airzone.const import DOMAIN
@@ -63,13 +64,18 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
-CONFIG = {
+USER_INPUT = {
CONF_HOST: "192.168.1.100",
CONF_PORT: 3000,
}
+CONFIG = {
+ **USER_INPUT,
+ CONF_ID: DEFAULT_SYSTEM_ID,
+}
+
CONFIG_ID1 = {
- **CONFIG,
+ **USER_INPUT,
CONF_ID: 1,
}
@@ -359,6 +365,7 @@ async def async_init_integration(
"""Set up the Airzone integration in Home Assistant."""
config_entry = MockConfigEntry(
+ minor_version=2,
data=CONFIG,
entry_id="6e7a0798c1734ba81d26ced0e690eaec",
domain=DOMAIN,
diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py
index 17a301ccdf1..3efacb80560 100644
--- a/tests/components/alarm_control_panel/test_device_trigger.py
+++ b/tests/components/alarm_control_panel/test_device_trigger.py
@@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import (
MockConfigEntry,
diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py
index 263fb69c883..27997a093e5 100644
--- a/tests/components/alert/test_init.py
+++ b/tests/components/alert/test_init.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
-from tests.common import async_mock_service
+from tests.common import MockEntityPlatform, async_mock_service
NAME = "alert_test"
DONE_MESSAGE = "alert_gone"
@@ -338,6 +338,7 @@ async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall])
async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -> None:
"""Test that the done message is reset when canceled."""
entity = alert.AlertEntity(hass, *TEST_NOACK)
+ entity.platform = MockEntityPlatform(hass)
entity._cancel = lambda *args: None
assert entity._send_done_message is False
entity._send_done_message = True
diff --git a/tests/components/androidtv/test_remote.py b/tests/components/androidtv/test_remote.py
index d18e08d4df8..5a52fe52b3a 100644
--- a/tests/components/androidtv/test_remote.py
+++ b/tests/components/androidtv/test_remote.py
@@ -99,9 +99,9 @@ async def test_services_remote(hass: HomeAssistant, config) -> None:
"adb_shell",
{ATTR_COMMAND: ["BACK", "test"], ATTR_NUM_REPEATS: 2},
[
- f"input keyevent {KEYS["BACK"]}",
+ f"input keyevent {KEYS['BACK']}",
"test",
- f"input keyevent {KEYS["BACK"]}",
+ f"input keyevent {KEYS['BACK']}",
"test",
],
)
diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py
index 02e15bca415..0968ea5acff 100644
--- a/tests/components/androidtv_remote/test_config_flow.py
+++ b/tests/components/androidtv_remote/test_config_flow.py
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.androidtv_remote.config_flow import (
APPS_NEW_ID,
CONF_APP_DELETE,
@@ -22,6 +21,7 @@ from homeassistant.components.androidtv_remote.const import (
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
@@ -444,7 +444,7 @@ async def test_zeroconf_flow_success(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address(host),
ip_addresses=[ip_address(host)],
port=6466,
@@ -522,7 +522,7 @@ async def test_zeroconf_flow_cannot_connect(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address(host),
ip_addresses=[ip_address(host)],
port=6466,
@@ -573,7 +573,7 @@ async def test_zeroconf_flow_pairing_invalid_auth(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address(host),
ip_addresses=[ip_address(host)],
port=6466,
@@ -657,7 +657,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address(host),
ip_addresses=[ip_address(host)],
port=6466,
@@ -710,7 +710,7 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address(host),
ip_addresses=[ip_address(host)],
port=6466,
@@ -743,7 +743,7 @@ async def test_zeroconf_flow_abort_if_mac_is_missing(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address(host),
ip_addresses=[ip_address(host)],
port=6466,
@@ -787,7 +787,7 @@ async def test_zeroconf_flow_already_configured_zeroconf_has_multiple_invalid_ip
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("1.2.3.5"),
ip_addresses=[ip_address("1.2.3.5"), ip_address(host)],
port=6466,
diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py
index 65ede877281..fa5bcb8137a 100644
--- a/tests/components/anthropic/test_conversation.py
+++ b/tests/components/anthropic/test_conversation.py
@@ -16,7 +16,7 @@ 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 homeassistant.util import ulid as ulid_util
from tests.common import MockConfigEntry
@@ -127,9 +127,9 @@ async def test_template_variables(
hass, "hello", None, context, agent_id="conversation.claude"
)
- assert (
- result.response.response_type == intent.IntentResponseType.ACTION_DONE
- ), result
+ 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"]
@@ -472,7 +472,7 @@ async def test_conversation_id(
assert result.conversation_id == conversation_id
- unknown_id = ulid.ulid()
+ unknown_id = ulid_util.ulid()
result = await conversation.async_converse(
hass, "hello", unknown_id, None, agent_id="conversation.claude"
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index abce262fd12..6363304effc 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -11,10 +11,9 @@ from aiohttp.test_utils import TestClient
import pytest
import voluptuous as vol
-from homeassistant import const
+from homeassistant import const, core as ha
from homeassistant.auth.models import Credentials
from homeassistant.bootstrap import DATA_LOGGING
-import homeassistant.core as ha
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py
index 4567bd32582..a13eb3c605b 100644
--- a/tests/components/apple_tv/test_config_flow.py
+++ b/tests/components/apple_tv/test_config_flow.py
@@ -9,7 +9,6 @@ from pyatv.const import PairingRequirement, Protocol
import pytest
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.apple_tv import CONF_ADDRESS, config_flow
from homeassistant.components.apple_tv.const import (
CONF_IDENTIFIERS,
@@ -19,12 +18,13 @@ from homeassistant.components.apple_tv.const import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .common import airplay_service, create_conf, mrp_service, raop_service
from tests.common import MockConfigEntry
-DMAP_SERVICE = zeroconf.ZeroconfServiceInfo(
+DMAP_SERVICE = ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -35,7 +35,7 @@ DMAP_SERVICE = zeroconf.ZeroconfServiceInfo(
)
-RAOP_SERVICE = zeroconf.ZeroconfServiceInfo(
+RAOP_SERVICE = ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -566,7 +566,7 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -586,7 +586,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None:
unrelated_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.2"),
ip_addresses=[ip_address("127.0.0.2")],
hostname="mock_hostname",
@@ -601,7 +601,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -883,7 +883,7 @@ async def test_zeroconf_abort_if_other_in_progress(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -906,7 +906,7 @@ async def test_zeroconf_abort_if_other_in_progress(
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -933,7 +933,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -955,7 +955,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve(
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -992,7 +992,7 @@ async def test_zeroconf_additional_protocol_resolve_failure(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -1014,7 +1014,7 @@ async def test_zeroconf_additional_protocol_resolve_failure(
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -1053,7 +1053,7 @@ async def test_zeroconf_pair_additionally_found_protocols(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -1096,7 +1096,7 @@ async def test_zeroconf_pair_additionally_found_protocols(
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -1158,7 +1158,7 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -1242,7 +1242,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"),
ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")],
hostname="mock_hostname",
diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py
index 7a48ff7db3f..92af6885c0b 100644
--- a/tests/components/apsystems/conftest.py
+++ b/tests/components/apsystems/conftest.py
@@ -38,7 +38,7 @@ def mock_apsystems() -> Generator[MagicMock]:
mock_api = mock_client.return_value
mock_api.get_device_info.return_value = ReturnDeviceInfo(
deviceId="MY_SERIAL_NUMBER",
- devVer="1.0.0",
+ devVer="EZ1 1.0.0",
ssid="MY_SSID",
ipAddr="127.0.01",
minPower=0,
diff --git a/tests/components/aranet/test_config_flow.py b/tests/components/aranet/test_config_flow.py
index 9596507960b..c40725c397d 100644
--- a/tests/components/aranet/test_config_flow.py
+++ b/tests/components/aranet/test_config_flow.py
@@ -4,6 +4,7 @@ from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.aranet.const import DOMAIN
+from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -275,3 +276,31 @@ async def test_async_step_user_integrations_disabled(hass: HomeAssistant) -> Non
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "integrations_disabled"
+
+
+async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
+ """Test the user initiated form can replace an ignored device."""
+ entry = MockConfigEntry(
+ domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", source=SOURCE_IGNORE
+ )
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.aranet.config_flow.async_discovered_service_info",
+ return_value=[VALID_DATA_SERVICE_INFO],
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ with patch("homeassistant.components.aranet.async_setup_entry", return_value=True):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"address": "aa:bb:cc:dd:ee:ff"}
+ )
+ await hass.async_block_till_done()
+ assert result2["type"] is FlowResultType.CREATE_ENTRY
+ assert result2["title"] == "Aranet4 12345"
+ assert result2["data"] == {}
+ assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff"
diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py
index 7bd00af4837..78a1d4aa9c9 100644
--- a/tests/components/aranet/test_sensor.py
+++ b/tests/components/aranet/test_sensor.py
@@ -6,6 +6,7 @@ from homeassistant.components.aranet.const import DOMAIN
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import (
DISABLED_INTEGRATIONS_SERVICE_INFO,
@@ -20,7 +21,11 @@ from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
-async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None:
+async def test_sensors_aranet_radiation(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
"""Test setting up creates the sensors for Aranet Radiation device."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -73,12 +78,24 @@ async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None:
assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+ # Check device context for the battery sensor
+ entity = entity_registry.async_get("sensor.aranet_12345_battery")
+ device = device_registry.async_get(entity.device_id)
+ assert device.name == "Aranet☢ 12345"
+ assert device.model == "Aranet Radiation"
+ assert device.sw_version == "v1.4.38"
+ assert device.manufacturer == "SAF Tehnika"
+
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
-async def test_sensors_aranet2(hass: HomeAssistant) -> None:
+async def test_sensors_aranet2(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
"""Test setting up creates the sensors for Aranet2 device."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -122,12 +139,24 @@ async def test_sensors_aranet2(hass: HomeAssistant) -> None:
assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+ # Check device context for the battery sensor
+ entity = entity_registry.async_get("sensor.aranet2_12345_battery")
+ device = device_registry.async_get(entity.device_id)
+ assert device.name == "Aranet2 12345"
+ assert device.model == "Aranet2"
+ assert device.sw_version == "v1.4.4"
+ assert device.manufacturer == "SAF Tehnika"
+
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
-async def test_sensors_aranet4(hass: HomeAssistant) -> None:
+async def test_sensors_aranet4(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
"""Test setting up creates the sensors for Aranet4 device."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -185,12 +214,24 @@ async def test_sensors_aranet4(hass: HomeAssistant) -> None:
assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+ # Check device context for the battery sensor
+ entity = entity_registry.async_get("sensor.aranet4_12345_battery")
+ device = device_registry.async_get(entity.device_id)
+ assert device.name == "Aranet4 12345"
+ assert device.model == "Aranet4"
+ assert device.sw_version == "v1.2.0"
+ assert device.manufacturer == "SAF Tehnika"
+
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
-async def test_sensors_aranetrn(hass: HomeAssistant) -> None:
+async def test_sensors_aranetrn(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
"""Test setting up creates the sensors for Aranet Radon device."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -250,6 +291,14 @@ async def test_sensors_aranetrn(hass: HomeAssistant) -> None:
assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+ # Check device context for the battery sensor
+ entity = entity_registry.async_get("sensor.aranetrn_12345_battery")
+ device = device_registry.async_get(entity.device_id)
+ assert device.name == "AranetRn+ 12345"
+ assert device.model == "Aranet Radon Plus"
+ assert device.sw_version == "v1.6.4"
+ assert device.manufacturer == "SAF Tehnika"
+
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py
index 60c68c5e102..1a578fc613d 100644
--- a/tests/components/arcam_fmj/test_config_flow.py
+++ b/tests/components/arcam_fmj/test_config_flow.py
@@ -7,12 +7,21 @@ from unittest.mock import AsyncMock, MagicMock, patch
from arcam.fmj.client import ConnectionFailed
import pytest
-from homeassistant.components import ssdp
from homeassistant.components.arcam_fmj.const import DOMAIN
from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .conftest import (
MOCK_CONFIG_ENTRY,
@@ -36,18 +45,18 @@ MOCK_UPNP_DEVICE = f"""
MOCK_UPNP_LOCATION = f"http://{MOCK_HOST}:8080/dd.xml"
-MOCK_DISCOVER = ssdp.SsdpServiceInfo(
+MOCK_DISCOVER = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=f"http://{MOCK_HOST}:8080/dd.xml",
upnp={
- ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM",
- ssdp.ATTR_UPNP_MODEL_NAME: " ",
- ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750",
- ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}",
- ssdp.ATTR_UPNP_SERIAL: "12343",
- ssdp.ATTR_UPNP_UDN: MOCK_UDN,
- ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1",
+ ATTR_UPNP_MANUFACTURER: "ARCAM",
+ ATTR_UPNP_MODEL_NAME: " ",
+ ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750",
+ ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}",
+ ATTR_UPNP_SERIAL: "12343",
+ ATTR_UPNP_UDN: MOCK_UDN,
+ ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1",
},
)
@@ -115,7 +124,7 @@ async def test_ssdp_unable_to_connect(
async def test_ssdp_invalid_id(hass: HomeAssistant) -> None:
"""Test a ssdp with invalid UDN."""
discover = replace(
- MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ssdp.ATTR_UPNP_UDN: "invalid"}
+ MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ATTR_UPNP_UDN: "invalid"}
)
result = await hass.config_entries.flow.async_init(
diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr
index 171014fdc4a..526e1bff151 100644
--- a/tests/components/assist_pipeline/snapshots/test_init.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_init.ambr
@@ -44,7 +44,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -135,7 +135,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -226,7 +226,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -341,7 +341,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -446,7 +446,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -497,7 +497,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -548,7 +548,7 @@
dict({
'data': dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
index 41747a50eb6..5f06172404b 100644
--- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
@@ -42,7 +42,7 @@
# name: test_audio_pipeline.4
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -125,7 +125,7 @@
# name: test_audio_pipeline_debug.4
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -220,7 +220,7 @@
# name: test_audio_pipeline_with_enhancements.4
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -325,7 +325,7 @@
# name: test_audio_pipeline_with_wake_word_no_timeout.6
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -585,7 +585,7 @@
# name: test_pipeline_empty_tts_output.2
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -698,7 +698,7 @@
# name: test_text_only_pipeline[extra_msg0].2
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -710,7 +710,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any area called Are',
+ 'speech': 'Sorry, I am not aware of any area called Are the',
}),
}),
}),
@@ -744,7 +744,7 @@
# name: test_text_only_pipeline[extra_msg1].2
dict({
'intent_output': dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -756,7 +756,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any area called Are',
+ 'speech': 'Sorry, I am not aware of any area called Are the',
}),
}),
}),
diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py
index 9e9bfd959e6..0cc0e94e149 100644
--- a/tests/components/assist_satellite/conftest.py
+++ b/tests/components/assist_satellite/conftest.py
@@ -16,7 +16,9 @@ from homeassistant.components.assist_satellite import (
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity import DeviceInfo
from homeassistant.setup import async_setup_component
+from homeassistant.util.ulid import ulid_hex
from tests.common import (
MockConfigEntry,
@@ -38,11 +40,19 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None:
class MockAssistSatellite(AssistSatelliteEntity):
"""Mock Assist Satellite Entity."""
- _attr_name = "Test Entity"
- _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE
+ _attr_tts_options = {"test-option": "test-value"}
- def __init__(self) -> None:
+ def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None:
"""Initialize the mock entity."""
+ self._attr_unique_id = ulid_hex()
+ self._attr_device_info = DeviceInfo(
+ {
+ "name": name,
+ "identifiers": {(TEST_DOMAIN, self._attr_unique_id)},
+ }
+ )
+ self._attr_name = name
+ self._attr_supported_features = features
self.events = []
self.announcements: list[AssistSatelliteAnnouncement] = []
self.config = AssistSatelliteConfiguration(
@@ -59,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity):
active_wake_words=["1234"],
max_active_wake_words=1,
)
+ self.start_conversations = []
def on_pipeline_event(self, event: PipelineEvent) -> None:
"""Handle pipeline events."""
@@ -79,11 +90,33 @@ class MockAssistSatellite(AssistSatelliteEntity):
"""Set the current satellite configuration."""
self.config = config
+ async def async_start_conversation(
+ self, start_announcement: AssistSatelliteConfiguration
+ ) -> None:
+ """Start a conversation from the satellite."""
+ self.start_conversations.append((self._extra_system_prompt, start_announcement))
+
@pytest.fixture
def entity() -> MockAssistSatellite:
"""Mock Assist Satellite Entity."""
- return MockAssistSatellite()
+ return MockAssistSatellite(
+ "Test Entity",
+ AssistSatelliteEntityFeature.ANNOUNCE
+ | AssistSatelliteEntityFeature.START_CONVERSATION,
+ )
+
+
+@pytest.fixture
+def entity2() -> MockAssistSatellite:
+ """Mock a second Assist Satellite Entity."""
+ return MockAssistSatellite("Test Entity 2", AssistSatelliteEntityFeature.ANNOUNCE)
+
+
+@pytest.fixture
+def entity_no_features() -> MockAssistSatellite:
+ """Mock a third Assist Satellite Entity."""
+ return MockAssistSatellite("Test Entity No features", 0)
@pytest.fixture
@@ -99,6 +132,8 @@ async def init_components(
hass: HomeAssistant,
config_entry: ConfigEntry,
entity: MockAssistSatellite,
+ entity2: MockAssistSatellite,
+ entity_no_features: MockAssistSatellite,
) -> None:
"""Initialize components."""
assert await async_setup_component(hass, "homeassistant", {})
@@ -125,7 +160,9 @@ async def init_components(
async_unload_entry=async_unload_entry_init,
),
)
- setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True)
+ setup_test_component_platform(
+ hass, AS_DOMAIN, [entity, entity2, entity_no_features], from_config_entry=True
+ )
mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock())
with mock_config_flow(TEST_DOMAIN, ConfigFlow):
diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py
index 884ba36782c..46facb80844 100644
--- a/tests/components/assist_satellite/test_entity.py
+++ b/tests/components/assist_satellite/test_entity.py
@@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat
from homeassistant.components.media_source import PlayMedia
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from . import ENTITY_ID
from .conftest import MockAssistSatellite
+@pytest.fixture(autouse=True)
+async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None:
+ """Set up a pipeline with a TTS engine."""
+ await async_update_pipeline(
+ hass,
+ async_get_pipeline(hass),
+ tts_engine="tts.mock_entity",
+ tts_language="en",
+ tts_voice="test-voice",
+ )
+
+
async def test_entity_state(
hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite
) -> None:
@@ -63,8 +76,8 @@ async def test_entity_state(
)
assert kwargs["stt_stream"] is audio_stream
assert kwargs["pipeline_id"] is None
- assert kwargs["device_id"] is None
- assert kwargs["tts_audio_output"] is None
+ assert kwargs["device_id"] is entity.device_entry.id
+ assert kwargs["tts_audio_output"] == {"test-option": "test-value"}
assert kwargs["wake_word_phrase"] is None
assert kwargs["audio_settings"] == AudioSettings(
silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT)
@@ -163,21 +176,32 @@ async def test_new_pipeline_cancels_pipeline(
(
{"message": "Hello"},
AssistSatelliteAnnouncement(
- "Hello", "https://www.home-assistant.io/resolved.mp3", "tts"
+ message="Hello",
+ media_id="https://www.home-assistant.io/resolved.mp3",
+ original_media_id="media-source://bla",
+ media_id_source="tts",
),
),
(
{
"message": "Hello",
- "media_id": "media-source://bla",
+ "media_id": "media-source://given",
},
AssistSatelliteAnnouncement(
- "Hello", "https://www.home-assistant.io/resolved.mp3", "media_id"
+ message="Hello",
+ media_id="https://www.home-assistant.io/resolved.mp3",
+ original_media_id="media-source://given",
+ media_id_source="media_id",
),
),
(
{"media_id": "http://example.com/bla.mp3"},
- AssistSatelliteAnnouncement("", "http://example.com/bla.mp3", "url"),
+ AssistSatelliteAnnouncement(
+ message="",
+ media_id="http://example.com/bla.mp3",
+ original_media_id="http://example.com/bla.mp3",
+ media_id_source="url",
+ ),
),
],
)
@@ -189,24 +213,12 @@ async def test_announce(
expected_params: tuple[str, str],
) -> None:
"""Test announcing on a device."""
- await async_update_pipeline(
- hass,
- async_get_pipeline(hass),
- tts_engine="tts.mock_entity",
- tts_language="en",
- tts_voice="test-voice",
- )
-
- entity._attr_tts_options = {"test-option": "test-value"}
-
original_announce = entity.async_announce
- announce_started = asyncio.Event()
async def async_announce(announcement):
# Verify state change
assert entity.state == AssistSatelliteState.RESPONDING
await original_announce(announcement)
- announce_started.set()
def tts_generate_media_source_id(
hass: HomeAssistant,
@@ -464,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found(
with pytest.raises(RuntimeError):
await entity.async_accept_pipeline_from_satellite(audio_stream)
+
+
+@pytest.mark.parametrize(
+ ("service_data", "expected_params"),
+ [
+ (
+ {
+ "start_message": "Hello",
+ "extra_system_prompt": "Better system prompt",
+ },
+ (
+ "Better system prompt",
+ AssistSatelliteAnnouncement(
+ message="Hello",
+ media_id="https://www.home-assistant.io/resolved.mp3",
+ original_media_id="media-source://generated",
+ media_id_source="tts",
+ ),
+ ),
+ ),
+ (
+ {
+ "start_message": "Hello",
+ "start_media_id": "media-source://given",
+ },
+ (
+ "Hello",
+ AssistSatelliteAnnouncement(
+ message="Hello",
+ media_id="https://www.home-assistant.io/resolved.mp3",
+ original_media_id="media-source://given",
+ media_id_source="media_id",
+ ),
+ ),
+ ),
+ (
+ {"start_media_id": "http://example.com/given.mp3"},
+ (
+ None,
+ AssistSatelliteAnnouncement(
+ message="",
+ media_id="http://example.com/given.mp3",
+ original_media_id="http://example.com/given.mp3",
+ media_id_source="url",
+ ),
+ ),
+ ),
+ ],
+)
+async def test_start_conversation(
+ hass: HomeAssistant,
+ init_components: ConfigEntry,
+ entity: MockAssistSatellite,
+ service_data: dict,
+ expected_params: tuple[str, str],
+) -> None:
+ """Test starting a conversation on a device."""
+ await async_update_pipeline(
+ hass,
+ async_get_pipeline(hass),
+ conversation_engine="conversation.some_llm",
+ )
+
+ with (
+ patch(
+ "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id",
+ return_value="media-source://generated",
+ ),
+ patch(
+ "homeassistant.components.media_source.async_resolve_media",
+ return_value=PlayMedia(
+ url="https://www.home-assistant.io/resolved.mp3",
+ mime_type="audio/mp3",
+ ),
+ ),
+ ):
+ await hass.services.async_call(
+ "assist_satellite",
+ "start_conversation",
+ service_data,
+ target={"entity_id": "assist_satellite.test_entity"},
+ blocking=True,
+ )
+
+ assert entity.start_conversations[0] == expected_params
+
+
+async def test_start_conversation_reject_builtin_agent(
+ hass: HomeAssistant,
+ init_components: ConfigEntry,
+ entity: MockAssistSatellite,
+) -> None:
+ """Test starting a conversation on a device."""
+ with pytest.raises(HomeAssistantError):
+ await hass.services.async_call(
+ "assist_satellite",
+ "start_conversation",
+ {"start_message": "Hey!"},
+ target={"entity_id": "assist_satellite.test_entity"},
+ blocking=True,
+ )
diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py
new file mode 100644
index 00000000000..27107c7d2e9
--- /dev/null
+++ b/tests/components/assist_satellite/test_intent.py
@@ -0,0 +1,110 @@
+"""Test assist satellite intents."""
+
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.components.media_source import PlayMedia
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import intent
+
+from .conftest import MockAssistSatellite
+
+
+@pytest.fixture
+def mock_tts():
+ """Mock TTS service."""
+ with (
+ patch(
+ "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id",
+ return_value="media-source://bla",
+ ),
+ patch(
+ "homeassistant.components.media_source.async_resolve_media",
+ return_value=PlayMedia(
+ url="https://www.home-assistant.io/resolved.mp3",
+ mime_type="audio/mp3",
+ ),
+ ),
+ ):
+ yield
+
+
+async def test_broadcast_intent(
+ hass: HomeAssistant,
+ init_components: ConfigEntry,
+ entity: MockAssistSatellite,
+ entity2: MockAssistSatellite,
+ entity_no_features: MockAssistSatellite,
+ mock_tts: None,
+) -> None:
+ """Test we can invoke a broadcast intent."""
+
+ result = await intent.async_handle(
+ hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}}
+ )
+
+ assert result.as_dict() == {
+ "card": {},
+ "data": {
+ "failed": [],
+ "success": [
+ {
+ "id": "assist_satellite.test_entity",
+ "name": "Test Entity",
+ "type": intent.IntentResponseTargetType.ENTITY,
+ },
+ {
+ "id": "assist_satellite.test_entity_2",
+ "name": "Test Entity 2",
+ "type": intent.IntentResponseTargetType.ENTITY,
+ },
+ ],
+ "targets": [],
+ },
+ "language": "en",
+ "response_type": "action_done",
+ "speech": {
+ "plain": {
+ "extra_data": None,
+ "speech": "Done",
+ }
+ },
+ }
+ assert len(entity.announcements) == 1
+ assert len(entity2.announcements) == 1
+ assert len(entity_no_features.announcements) == 0
+
+ result = await intent.async_handle(
+ hass,
+ "test",
+ intent.INTENT_BROADCAST,
+ {"message": {"value": "Hello"}},
+ device_id=entity.device_entry.id,
+ )
+ # Broadcast doesn't targets device that triggered it.
+ assert result.as_dict() == {
+ "card": {},
+ "data": {
+ "failed": [],
+ "success": [
+ {
+ "id": "assist_satellite.test_entity_2",
+ "name": "Test Entity 2",
+ "type": intent.IntentResponseTargetType.ENTITY,
+ },
+ ],
+ "targets": [],
+ },
+ "language": "en",
+ "response_type": "action_done",
+ "speech": {
+ "plain": {
+ "extra_data": None,
+ "speech": "Done",
+ }
+ },
+ }
+ assert len(entity.announcements) == 1
+ assert len(entity2.announcements) == 2
diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py
index 0036c40a6f2..929500f0bb7 100644
--- a/tests/components/asuswrt/test_sensor.py
+++ b/tests/components/asuswrt/test_sensor.py
@@ -82,6 +82,7 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None):
options={CONF_CONSIDER_HOME: 60},
unique_id=unique_id,
)
+ config_entry.add_to_hass(hass)
# init variable
obj_prefix = slugify(HOST)
@@ -131,8 +132,6 @@ async def _test_sensors(
disabled_by=None,
)
- config_entry.add_to_hass(hass)
-
# initial devices setup
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py
index 4ae300ae56b..bcdd4d55330 100644
--- a/tests/components/august/test_binary_sensor.py
+++ b/tests/components/august/test_binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .mocks import (
_create_august_with_devices,
diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py
index eb177a35cfb..065ffef91ff 100644
--- a/tests/components/august/test_lock.py
+++ b/tests/components/august/test_lock.py
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotSupported
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .mocks import (
_create_august_with_devices,
diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py
index 4bc5a5d3086..0de8d923bb8 100644
--- a/tests/components/aurora_abb_powerone/test_sensor.py
+++ b/tests/components/aurora_abb_powerone/test_sensor.py
@@ -123,9 +123,9 @@ async def test_sensors(hass: HomeAssistant, entity_registry: EntityRegistry) ->
]
for entity_id, _ in sensors:
assert not hass.states.get(entity_id)
- assert (
- entry := entity_registry.async_get(entity_id)
- ), f"Entity registry entry for {entity_id} is missing"
+ assert (entry := entity_registry.async_get(entity_id)), (
+ f"Entity registry entry for {entity_id} is missing"
+ )
assert entry.disabled
assert entry.disabled_by is RegistryEntryDisabler.INTEGRATION
diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py
index 1095c625fb2..1e7c616efeb 100644
--- a/tests/components/automation/test_blueprint.py
+++ b/tests/components/automation/test_blueprint.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
-from homeassistant.util import dt as dt_util, yaml
+from homeassistant.util import dt as dt_util, yaml as yaml_util
from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service
@@ -38,7 +38,7 @@ def patch_blueprint(
return orig_load(self, path)
return models.Blueprint(
- yaml.load_yaml(data_path),
+ yaml_util.load_yaml(data_path),
expected_domain=self.domain,
path=path,
schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA,
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 98d8bf0396e..243e132dae2 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -51,8 +51,7 @@ from homeassistant.helpers.script import (
_async_stop_scripts_at_shutdown,
)
from homeassistant.setup import async_setup_component
-from homeassistant.util import yaml
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util, yaml as yaml_util
from tests.common import (
MockConfigEntry,
@@ -1376,7 +1375,9 @@ async def test_reload_automation_when_blueprint_changes(
# Reload the automations without any change, but with updated blueprint
blueprint_path = automation.async_get_blueprints(hass).blueprint_folder
- blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml")
+ blueprint_config = yaml_util.load_yaml(
+ blueprint_path / "test_event_service.yaml"
+ )
blueprint_config["actions"] = [blueprint_config["actions"]]
blueprint_config["actions"].append(blueprint_config["actions"][-1])
@@ -1387,7 +1388,7 @@ async def test_reload_automation_when_blueprint_changes(
return_value=config,
),
patch(
- "homeassistant.components.blueprint.models.yaml.load_yaml_dict",
+ "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict",
autospec=True,
return_value=blueprint_config,
),
@@ -2691,7 +2692,7 @@ async def test_blueprint_automation_fails_substitution(
"""Test blueprint automation with bad inputs."""
with patch(
"homeassistant.components.blueprint.models.BlueprintInputs.async_substitute",
- side_effect=yaml.UndefinedSubstitution("blah"),
+ side_effect=yaml_util.UndefinedSubstitution("blah"),
):
assert await async_setup_component(
hass,
diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py
index f24eaeb971d..6dc9118f511 100644
--- a/tests/components/awair/const.py
+++ b/tests/components/awair/const.py
@@ -2,15 +2,15 @@
from ipaddress import ip_address
-from homeassistant.components import zeroconf
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
AWAIR_UUID = "awair_24947"
CLOUD_CONFIG = {CONF_ACCESS_TOKEN: "12345"}
LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"}
CLOUD_UNIQUE_ID = "foo@bar.com"
LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26"
-ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo(
+ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("192.0.2.5"),
ip_addresses=[ip_address("192.0.2.5")],
hostname="mock_hostname",
diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py
index 6cc4bbd7c2f..9dcfbac4e7b 100644
--- a/tests/components/axis/test_camera.py
+++ b/tests/components/axis/test_camera.py
@@ -63,12 +63,12 @@ async def test_camera(
assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
assert (
camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"
- f"{"" if not stream_profile else f"?{stream_profile}"}"
+ f"{'' if not stream_profile else f'?{stream_profile}'}"
)
assert (
await camera_entity.stream_source()
== "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264"
- f"{"" if not stream_profile else f"&{stream_profile}"}"
+ f"{'' if not stream_profile else f'&{stream_profile}'}"
)
diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py
index 52dd9c2f8ad..c7c3097aaaa 100644
--- a/tests/components/axis/test_config_flow.py
+++ b/tests/components/axis/test_config_flow.py
@@ -6,7 +6,6 @@ from unittest.mock import patch
import pytest
-from homeassistant.components import dhcp, ssdp, zeroconf
from homeassistant.components.axis import config_flow
from homeassistant.components.axis.const import (
CONF_STREAM_PROFILE,
@@ -33,6 +32,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DEFAULT_HOST, MAC, MODEL, NAME
@@ -268,7 +270,7 @@ async def test_reconfiguration_flow_update_configuration(
[
(
SOURCE_DHCP,
- dhcp.DhcpServiceInfo(
+ DhcpServiceInfo(
hostname=f"axis-{MAC}",
ip=DEFAULT_HOST,
macaddress=DHCP_FORMATTED_MAC,
@@ -276,7 +278,7 @@ async def test_reconfiguration_flow_update_configuration(
),
(
SOURCE_SSDP,
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
upnp={
@@ -312,7 +314,7 @@ async def test_reconfiguration_flow_update_configuration(
),
(
SOURCE_ZEROCONF,
- zeroconf.ZeroconfServiceInfo(
+ ZeroconfServiceInfo(
ip_address=ip_address(DEFAULT_HOST),
ip_addresses=[ip_address(DEFAULT_HOST)],
port=80,
@@ -376,7 +378,7 @@ async def test_discovery_flow(
[
(
SOURCE_DHCP,
- dhcp.DhcpServiceInfo(
+ DhcpServiceInfo(
hostname=f"axis-{MAC}",
ip=DEFAULT_HOST,
macaddress=DHCP_FORMATTED_MAC,
@@ -384,7 +386,7 @@ async def test_discovery_flow(
),
(
SOURCE_SSDP,
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
upnp={
@@ -396,7 +398,7 @@ async def test_discovery_flow(
),
(
SOURCE_ZEROCONF,
- zeroconf.ZeroconfServiceInfo(
+ ZeroconfServiceInfo(
ip_address=ip_address(DEFAULT_HOST),
ip_addresses=[ip_address(DEFAULT_HOST)],
hostname="mock_hostname",
@@ -431,7 +433,7 @@ async def test_discovered_device_already_configured(
[
(
SOURCE_DHCP,
- dhcp.DhcpServiceInfo(
+ DhcpServiceInfo(
hostname=f"axis-{MAC}",
ip="2.3.4.5",
macaddress=DHCP_FORMATTED_MAC,
@@ -440,7 +442,7 @@ async def test_discovered_device_already_configured(
),
(
SOURCE_SSDP,
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
upnp={
@@ -453,7 +455,7 @@ async def test_discovered_device_already_configured(
),
(
SOURCE_ZEROCONF,
- zeroconf.ZeroconfServiceInfo(
+ ZeroconfServiceInfo(
ip_address=ip_address("2.3.4.5"),
ip_addresses=[ip_address("2.3.4.5")],
hostname="mock_hostname",
@@ -507,7 +509,7 @@ async def test_discovery_flow_updated_configuration(
[
(
SOURCE_DHCP,
- dhcp.DhcpServiceInfo(
+ DhcpServiceInfo(
hostname="",
ip="",
macaddress=dr.format_mac("01234567890").replace(":", ""),
@@ -515,7 +517,7 @@ async def test_discovery_flow_updated_configuration(
),
(
SOURCE_SSDP,
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
upnp={
@@ -527,7 +529,7 @@ async def test_discovery_flow_updated_configuration(
),
(
SOURCE_ZEROCONF,
- zeroconf.ZeroconfServiceInfo(
+ ZeroconfServiceInfo(
ip_address=None,
ip_addresses=[],
hostname="mock_hostname",
@@ -556,7 +558,7 @@ async def test_discovery_flow_ignore_non_axis_device(
[
(
SOURCE_DHCP,
- dhcp.DhcpServiceInfo(
+ DhcpServiceInfo(
hostname=f"axis-{MAC}",
ip="169.254.3.4",
macaddress=DHCP_FORMATTED_MAC,
@@ -564,7 +566,7 @@ async def test_discovery_flow_ignore_non_axis_device(
),
(
SOURCE_SSDP,
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
upnp={
@@ -576,7 +578,7 @@ async def test_discovery_flow_ignore_non_axis_device(
),
(
SOURCE_ZEROCONF,
- zeroconf.ZeroconfServiceInfo(
+ ZeroconfServiceInfo(
ip_address=ip_address("169.254.3.4"),
ip_addresses=[ip_address("169.254.3.4")],
hostname="mock_hostname",
diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py
index 74cdb0164cd..b2f2d15d989 100644
--- a/tests/components/axis/test_hub.py
+++ b/tests/components/axis/test_hub.py
@@ -11,13 +11,14 @@ import axis as axislib
import pytest
from syrupy import SnapshotAssertion
-from homeassistant.components import axis, zeroconf
+from homeassistant.components import axis
from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .conftest import RtspEventMock, RtspStateType
from .const import (
@@ -93,7 +94,7 @@ async def test_update_address(
mock_requests("2.3.4.5")
await hass.config_entries.flow.async_init(
AXIS_DOMAIN,
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("2.3.4.5"),
ip_addresses=[ip_address("2.3.4.5")],
hostname="mock_hostname",
diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py
index 4f456cc6d72..1e7278134d4 100644
--- a/tests/components/backup/common.py
+++ b/tests/components/backup/common.py
@@ -2,10 +2,10 @@
from __future__ import annotations
-from collections.abc import AsyncIterator, Callable, Coroutine
+from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
from pathlib import Path
from typing import Any
-from unittest.mock import ANY, AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, Mock, patch
from homeassistant.components.backup import (
DOMAIN,
@@ -29,7 +29,7 @@ TEST_BACKUP_ABC123 = AgentBackup(
backup_id="abc123",
database_included=True,
date="1970-01-01T00:00:00.000Z",
- extra_metadata={"instance_id": ANY, "with_automatic_settings": True},
+ extra_metadata={"instance_id": "our_uuid", "with_automatic_settings": True},
folders=[Folder.MEDIA, Folder.SHARE],
homeassistant_included=True,
homeassistant_version="2024.12.0",
@@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup(
protected=False,
size=1,
)
+TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar")
TEST_DOMAIN = "test"
+async def aiter_from_iter(iterable: Iterable) -> AsyncIterator:
+ """Convert an iterable to an async iterator."""
+ for i in iterable:
+ yield i
+
+
class BackupAgentTest(BackupAgent):
"""Test backup agent."""
@@ -64,6 +71,7 @@ class BackupAgentTest(BackupAgent):
def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None:
"""Initialize the backup agent."""
self.name = name
+ self.unique_id = name
if backups is None:
backups = [
AgentBackup(
@@ -161,7 +169,13 @@ async def setup_backup_integration(
if with_hassio and agent_id == LOCAL_AGENT_ID:
continue
agent = hass.data[DATA_MANAGER].backup_agents[agent_id]
- agent._backups = {backups.backup_id: backups for backups in agent_backups}
+
+ async def open_stream() -> AsyncIterator[bytes]:
+ """Open a stream."""
+ return aiter_from_iter((b"backup data",))
+
+ for backup in agent_backups:
+ await agent.async_upload_backup(open_stream=open_stream, backup=backup)
if agent_id == LOCAL_AGENT_ID:
agent._loaded_backups = True
diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py
index ee855fb70f2..eb38399eb79 100644
--- a/tests/components/backup/conftest.py
+++ b/tests/components/backup/conftest.py
@@ -9,10 +9,23 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
+from homeassistant.components.backup import DOMAIN
from homeassistant.components.backup.manager import NewBackup, WrittenBackup
from homeassistant.core import HomeAssistant
-from .common import TEST_BACKUP_PATH_ABC123
+from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456
+
+from tests.common import get_fixture_path
+
+
+@pytest.fixture(name="instance_id", autouse=True)
+def instance_id_fixture(hass: HomeAssistant) -> Generator[None]:
+ """Mock instance ID."""
+ with patch(
+ "homeassistant.components.backup.manager.instance_id.async_get",
+ return_value="our_uuid",
+ ):
+ yield
@pytest.fixture(name="mocked_json_bytes")
@@ -35,10 +48,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]:
@pytest.fixture(name="path_glob")
-def path_glob_fixture() -> Generator[MagicMock]:
+def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]:
"""Mock path glob."""
with patch(
- "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123]
+ "pathlib.Path.glob",
+ return_value=[
+ Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123,
+ Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456,
+ ],
) as path_glob:
yield path_glob
@@ -69,9 +86,10 @@ def mock_create_backup() -> Generator[AsyncMock]:
"""Mock manager create backup."""
mock_written_backup = MagicMock(spec_set=WrittenBackup)
mock_written_backup.backup.backup_id = "abc123"
+ mock_written_backup.backup.protected = False
mock_written_backup.open_stream = AsyncMock()
mock_written_backup.release_stream = AsyncMock()
- fut = Future()
+ fut: Future[MagicMock] = Future()
fut.set_result(mock_written_backup)
with patch(
"homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup"
@@ -113,3 +131,18 @@ def mock_backup_generation_fixture(
),
):
yield
+
+
+@pytest.fixture
+def mock_backups() -> Generator[None]:
+ """Fixture to setup test backups."""
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.backup import backup as core_backup
+
+ class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent):
+ def __init__(self, hass: HomeAssistant) -> None:
+ super().__init__(hass)
+ self._backup_dir = get_fixture_path("test_backups", DOMAIN)
+
+ with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent):
+ yield
diff --git a/tests/components/backup/fixtures/test_backups/2bcb3113.tar b/tests/components/backup/fixtures/test_backups/2bcb3113.tar
new file mode 100644
index 00000000000..8a6556634f3
Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/2bcb3113.tar differ
diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar
new file mode 100644
index 00000000000..f3b2845d5eb
Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar differ
diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted
new file mode 100644
index 00000000000..c97533fc1af
Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted differ
diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr
index f21de9d9fad..28ee9b834c1 100644
--- a/tests/components/backup/snapshots/test_backup.ambr
+++ b/tests/components/backup/snapshots/test_backup.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_delete_backup[found_backups0-True-1]
+# name: test_delete_backup[found_backups0-abc123-1-unlink_path0]
dict({
'id': 1,
'result': dict({
@@ -10,7 +10,7 @@
'type': 'result',
})
# ---
-# name: test_delete_backup[found_backups1-False-0]
+# name: test_delete_backup[found_backups1-def456-1-unlink_path1]
dict({
'id': 1,
'result': dict({
@@ -21,7 +21,7 @@
'type': 'result',
})
# ---
-# name: test_delete_backup[found_backups2-True-0]
+# name: test_delete_backup[found_backups2-abc123-0-None]
dict({
'id': 1,
'result': dict({
@@ -32,13 +32,14 @@
'type': 'result',
})
# ---
-# name: test_load_backups[None]
+# name: test_load_backups[mock_read_backup]
dict({
'id': 1,
'result': dict({
'agents': list([
dict({
'agent_id': 'backup.local',
+ 'name': 'local',
}),
]),
}),
@@ -46,7 +47,7 @@
'type': 'result',
})
# ---
-# name: test_load_backups[None].1
+# name: test_load_backups[mock_read_backup].1
dict({
'id': 2,
'result': dict({
@@ -61,12 +62,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -76,13 +84,42 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
+ dict({
+ 'addons': list([
+ ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 1,
+ }),
+ }),
+ 'backup_id': 'def456',
+ 'database_included': False,
+ 'date': '1980-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'unknown_uuid',
+ 'with_automatic_settings': True,
+ }),
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
+ 'name': 'Test 2',
+ 'with_automatic_settings': None,
+ }),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -95,6 +132,7 @@
'agents': list([
dict({
'agent_id': 'backup.local',
+ 'name': 'local',
}),
]),
}),
@@ -112,6 +150,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -124,6 +166,7 @@
'agents': list([
dict({
'agent_id': 'backup.local',
+ 'name': 'local',
}),
]),
}),
@@ -141,6 +184,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -153,6 +200,7 @@
'agents': list([
dict({
'agent_id': 'backup.local',
+ 'name': 'local',
}),
]),
}),
@@ -170,6 +218,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -182,6 +234,7 @@
'agents': list([
dict({
'agent_id': 'backup.local',
+ 'name': 'local',
}),
]),
}),
@@ -199,6 +252,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr
new file mode 100644
index 00000000000..2fd81d6841a
--- /dev/null
+++ b/tests/components/backup/snapshots/test_store.ambr
@@ -0,0 +1,185 @@
+# serializer version: 1
+# name: test_store_migration[store_data0]
+ dict({
+ 'data': dict({
+ 'backups': list([
+ dict({
+ 'backup_id': 'abc123',
+ 'failed_agent_ids': list([
+ 'test.remote',
+ ]),
+ }),
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_store_migration[store_data0].1
+ dict({
+ 'data': dict({
+ 'backups': list([
+ dict({
+ 'backup_id': 'abc123',
+ 'failed_agent_ids': list([
+ 'test.remote',
+ ]),
+ }),
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_store_migration[store_data1]
+ dict({
+ 'data': dict({
+ 'backups': list([
+ dict({
+ 'backup_id': 'abc123',
+ 'failed_agent_ids': list([
+ 'test.remote',
+ ]),
+ }),
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': True,
+ }),
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ 'something_from_the_future': 'value',
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_store_migration[store_data1].1
+ dict({
+ 'data': dict({
+ 'backups': list([
+ dict({
+ 'backup_id': 'abc123',
+ 'failed_agent_ids': list([
+ 'test.remote',
+ ]),
+ }),
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': True,
+ }),
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr
index 98b2f764d43..421432fb66e 100644
--- a/tests/components/backup/snapshots/test_websocket.ambr
+++ b/tests/components/backup/snapshots/test_websocket.ambr
@@ -17,9 +17,11 @@
'agents': list([
dict({
'agent_id': 'backup.local',
+ 'name': 'local',
}),
dict({
- 'agent_id': 'domain.test',
+ 'agent_id': 'test.test',
+ 'name': 'test',
}),
]),
}),
@@ -175,11 +177,87 @@
'type': 'result',
})
# ---
-# name: test_config_info[None]
+# name: test_can_decrypt_on_download[backup.local-2bcb3113-hunter2]
+ dict({
+ 'error': dict({
+ 'code': 'decrypt_not_supported',
+ 'message': 'Decrypt on download not supported',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_can_decrypt_on_download[backup.local-c0cb53bd-hunter2]
+ dict({
+ 'id': 1,
+ 'result': None,
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_can_decrypt_on_download[backup.local-c0cb53bd-wrong_password]
+ dict({
+ 'error': dict({
+ 'code': 'password_incorrect',
+ 'message': 'Incorrect password',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_can_decrypt_on_download[backup.local-no_such_backup-hunter2]
+ dict({
+ 'error': dict({
+ 'code': 'home_assistant_error',
+ 'message': 'Backup no_such_backup not found in agent backup.local',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_can_decrypt_on_download[no_such_agent-c0cb53bd-hunter2]
+ dict({
+ 'error': dict({
+ 'code': 'home_assistant_error',
+ 'message': 'Invalid agent selected: no_such_agent',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError]
+ dict({
+ 'error': dict({
+ 'code': 'home_assistant_error',
+ 'message': 'Unknown error',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound]
+ dict({
+ 'error': dict({
+ 'code': 'backup_not_found',
+ 'message': 'Backup not found',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_info[storage_data0]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -192,12 +270,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -210,6 +293,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -227,12 +312,24 @@
}),
'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00',
'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00',
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': 3,
'days': 7,
}),
'schedule': dict({
- 'state': 'daily',
+ 'days': list([
+ 'mon',
+ 'tue',
+ 'wed',
+ 'thu',
+ 'fri',
+ 'sat',
+ 'sun',
+ ]),
+ 'recurrence': 'custom_days',
+ 'time': None,
}),
}),
}),
@@ -245,6 +342,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -258,12 +357,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': 3,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -276,6 +380,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -289,12 +395,17 @@
}),
'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00',
'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00',
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': 7,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -307,6 +418,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -320,12 +433,18 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-18T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'mon',
+ 'days': list([
+ 'mon',
+ ]),
+ 'recurrence': 'custom_days',
+ 'time': None,
}),
}),
}),
@@ -338,6 +457,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -351,12 +472,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'sat',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -364,11 +490,99 @@
'type': 'result',
})
# ---
-# name: test_config_update[command0]
+# name: test_config_info[storage_data6]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': False,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-17T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ 'mon',
+ 'sun',
+ ]),
+ 'recurrence': 'custom_days',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_info[storage_data7]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ 'test-agent1': dict({
+ 'protected': True,
+ }),
+ 'test-agent2': dict({
+ 'protected': False,
+ }),
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': False,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-17T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ 'mon',
+ 'sun',
+ ]),
+ 'recurrence': 'custom_days',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -381,12 +595,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -394,11 +613,52 @@
'type': 'result',
})
# ---
-# name: test_config_update[command0].1
+# name: test_config_update[commands0].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands0].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -417,7 +677,49 @@
'days': 7,
}),
'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands10]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -425,12 +727,166 @@
'type': 'result',
})
# ---
-# name: test_config_update[command0].2
+# name: test_config_update[commands10].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': 3,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands10].2
dict({
'data': dict({
'backups': list([
]),
'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': 3,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands11]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands11].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands11].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -449,20 +905,26 @@
'days': 7,
}),
'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
'state': 'never',
+ 'time': None,
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
-# name: test_config_update[command10]
+# name: test_config_update[commands12]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -475,12 +937,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -488,14 +955,21 @@
'type': 'result',
})
# ---
-# name: test_config_update[command10].1
+# name: test_config_update[commands12].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ 'test-agent1': dict({
+ 'protected': True,
+ }),
+ 'test-agent2': dict({
+ 'protected': False,
+ }),
+ }),
'create_backup': dict({
'agent_ids': list([
- 'test-agent',
]),
'include_addons': None,
'include_all_addons': False,
@@ -506,12 +980,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
- 'days': 7,
+ 'days': None,
}),
'schedule': dict({
- 'state': 'daily',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -519,15 +998,22 @@
'type': 'result',
})
# ---
-# name: test_config_update[command10].2
+# name: test_config_update[commands12].2
dict({
'data': dict({
'backups': list([
]),
'config': dict({
+ 'agents': dict({
+ 'test-agent1': dict({
+ 'protected': True,
+ }),
+ 'test-agent2': dict({
+ 'protected': False,
+ }),
+ }),
'create_backup': dict({
'agent_ids': list([
- 'test-agent',
]),
'include_addons': None,
'include_all_addons': False,
@@ -540,23 +1026,29 @@
'last_completed_automatic_backup': None,
'retention': dict({
'copies': None,
- 'days': 7,
+ 'days': None,
}),
'schedule': dict({
- 'state': 'daily',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'state': 'never',
+ 'time': None,
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
-# name: test_config_update[command1]
+# name: test_config_update[commands13]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -569,12 +1061,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -582,14 +1079,21 @@
'type': 'result',
})
# ---
-# name: test_config_update[command1].1
+# name: test_config_update[commands13].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ 'test-agent1': dict({
+ 'protected': True,
+ }),
+ 'test-agent2': dict({
+ 'protected': False,
+ }),
+ }),
'create_backup': dict({
'agent_ids': list([
- 'test-agent',
]),
'include_addons': None,
'include_all_addons': False,
@@ -600,12 +1104,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'daily',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -613,15 +1122,65 @@
'type': 'result',
})
# ---
-# name: test_config_update[command1].2
+# name: test_config_update[commands13].2
+ dict({
+ 'id': 5,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ 'test-agent1': dict({
+ 'protected': False,
+ }),
+ 'test-agent2': dict({
+ 'protected': True,
+ }),
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands13].3
dict({
'data': dict({
'backups': list([
]),
'config': dict({
+ 'agents': dict({
+ 'test-agent1': dict({
+ 'protected': False,
+ }),
+ 'test-agent2': dict({
+ 'protected': True,
+ }),
+ }),
'create_backup': dict({
'agent_ids': list([
- 'test-agent',
]),
'include_addons': None,
'include_all_addons': False,
@@ -637,20 +1196,26 @@
'days': None,
}),
'schedule': dict({
- 'state': 'daily',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'state': 'never',
+ 'time': None,
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
-# name: test_config_update[command2]
+# name: test_config_update[commands1]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -663,12 +1228,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -676,11 +1246,13 @@
'type': 'result',
})
# ---
-# name: test_config_update[command2].1
+# name: test_config_update[commands1].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -694,12 +1266,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T06:00:00+01:00',
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'mon',
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': '06:00:00',
}),
}),
}),
@@ -707,12 +1284,14 @@
'type': 'result',
})
# ---
-# name: test_config_update[command2].2
+# name: test_config_update[commands1].2
dict({
'data': dict({
'backups': list([
]),
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -731,20 +1310,26 @@
'days': None,
}),
'schedule': dict({
- 'state': 'mon',
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'state': 'never',
+ 'time': '06:00:00',
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
-# name: test_config_update[command3]
+# name: test_config_update[commands2]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -757,12 +1342,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -770,11 +1360,13 @@
'type': 'result',
})
# ---
-# name: test_config_update[command3].1
+# name: test_config_update[commands2].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -788,12 +1380,18 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-18T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ 'mon',
+ ]),
+ 'recurrence': 'custom_days',
+ 'time': None,
}),
}),
}),
@@ -801,12 +1399,14 @@
'type': 'result',
})
# ---
-# name: test_config_update[command3].2
+# name: test_config_update[commands2].2
dict({
'data': dict({
'backups': list([
]),
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -825,20 +1425,27 @@
'days': None,
}),
'schedule': dict({
+ 'days': list([
+ 'mon',
+ ]),
+ 'recurrence': 'custom_days',
'state': 'never',
+ 'time': None,
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
-# name: test_config_update[command4]
+# name: test_config_update[commands3]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -851,12 +1458,131 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands3].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands3].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands4]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -864,11 +1590,174 @@
'type': 'result',
})
# ---
-# name: test_config_update[command4].1
+# name: test_config_update[commands4].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-17T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ 'mon',
+ 'sun',
+ ]),
+ 'recurrence': 'custom_days',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands4].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ 'mon',
+ 'sun',
+ ]),
+ 'recurrence': 'custom_days',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands5]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands5].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': list([
+ 'test-addon',
+ ]),
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': list([
+ 'media',
+ ]),
+ 'name': 'test-name',
+ 'password': 'test-password',
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands5].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -891,56 +1780,26 @@
'days': None,
}),
'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command4].2
- dict({
- 'data': dict({
- 'backups': list([
- ]),
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
+ 'days': list([
]),
- 'include_addons': list([
- 'test-addon',
- ]),
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': list([
- 'media',
- ]),
- 'name': 'test-name',
- 'password': 'test-password',
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
+ 'recurrence': 'daily',
+ 'state': 'never',
+ 'time': None,
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
-# name: test_config_update[command5]
+# name: test_config_update[commands6]
dict({
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -953,12 +1812,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -966,11 +1830,52 @@
'type': 'result',
})
# ---
-# name: test_config_update[command5].1
+# name: test_config_update[commands6].1
dict({
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': 3,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands6].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -989,7 +1894,49 @@
'days': 7,
}),
'schedule': dict({
- 'state': 'daily',
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands7]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -997,12 +1944,166 @@
'type': 'result',
})
# ---
-# name: test_config_update[command5].2
+# name: test_config_update[commands7].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands7].2
dict({
'data': dict({
'backups': list([
]),
'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands8]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands8].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': 3,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands8].2
+ dict({
+ 'data': dict({
+ 'backups': list([
+ ]),
+ 'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -1018,41 +2119,90 @@
'last_completed_automatic_backup': None,
'retention': dict({
'copies': 3,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'daily',
+ 'state': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 3,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[commands9]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[commands9].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': '2024-11-14T04:55:00+01:00',
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
'days': 7,
}),
'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'key': 'backup',
- 'minor_version': 1,
- 'version': 1,
- })
-# ---
-# name: test_config_update[command6]
- dict({
- 'id': 1,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
+ 'days': list([
]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'never',
+ 'recurrence': 'daily',
+ 'time': None,
}),
}),
}),
@@ -1060,199 +2210,14 @@
'type': 'result',
})
# ---
-# name: test_config_update[command6].1
- dict({
- 'id': 3,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command6].2
+# name: test_config_update[commands9].2
dict({
'data': dict({
'backups': list([
]),
'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
+ 'agents': dict({
}),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'key': 'backup',
- 'minor_version': 1,
- 'version': 1,
- })
-# ---
-# name: test_config_update[command7]
- dict({
- 'id': 1,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'never',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command7].1
- dict({
- 'id': 3,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': 3,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command7].2
- dict({
- 'data': dict({
- 'backups': list([
- ]),
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': 3,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'key': 'backup',
- 'minor_version': 1,
- 'version': 1,
- })
-# ---
-# name: test_config_update[command8]
- dict({
- 'id': 1,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'never',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command8].1
- dict({
- 'id': 3,
- 'result': dict({
- 'config': dict({
'create_backup': dict({
'agent_ids': list([
'test-agent',
@@ -1271,138 +2236,16 @@
'days': 7,
}),
'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command8].2
- dict({
- 'data': dict({
- 'backups': list([
- ]),
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
+ 'days': list([
]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': 7,
- }),
- 'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'key': 'backup',
- 'minor_version': 1,
- 'version': 1,
- })
-# ---
-# name: test_config_update[command9]
- dict({
- 'id': 1,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': None,
- 'days': None,
- }),
- 'schedule': dict({
+ 'recurrence': 'daily',
'state': 'never',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command9].1
- dict({
- 'id': 3,
- 'result': dict({
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': 3,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
- }),
- }),
- }),
- 'success': True,
- 'type': 'result',
- })
-# ---
-# name: test_config_update[command9].2
- dict({
- 'data': dict({
- 'backups': list([
- ]),
- 'config': dict({
- 'create_backup': dict({
- 'agent_ids': list([
- 'test-agent',
- ]),
- 'include_addons': None,
- 'include_all_addons': False,
- 'include_database': True,
- 'include_folders': None,
- 'name': None,
- 'password': None,
- }),
- 'last_attempted_automatic_backup': None,
- 'last_completed_automatic_backup': None,
- 'retention': dict({
- 'copies': 3,
- 'days': None,
- }),
- 'schedule': dict({
- 'state': 'daily',
+ 'time': None,
}),
}),
}),
'key': 'backup',
- 'minor_version': 1,
+ 'minor_version': 3,
'version': 1,
})
# ---
@@ -1411,6 +2254,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1423,12 +2268,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1441,6 +2291,8 @@
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1453,12 +2305,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1471,6 +2328,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1483,12 +2342,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1501,6 +2365,8 @@
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1513,12 +2379,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1531,6 +2402,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1543,12 +2416,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1561,6 +2439,8 @@
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1573,12 +2453,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1591,6 +2476,8 @@
'id': 1,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1603,12 +2490,17 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1621,6 +2513,8 @@
'id': 3,
'result': dict({
'config': dict({
+ 'agents': dict({
+ }),
'create_backup': dict({
'agent_ids': list([
]),
@@ -1633,12 +2527,461 @@
}),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
'retention': dict({
'copies': None,
'days': None,
}),
'schedule': dict({
- 'state': 'never',
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command4]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command4].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command5]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command5].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command6]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command6].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command7]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command7].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command8]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command8].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command9]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update_errors[command9].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': dict({
+ 'agents': dict({
+ }),
+ 'create_backup': dict({
+ 'agent_ids': list([
+ ]),
+ 'include_addons': None,
+ 'include_all_addons': False,
+ 'include_database': True,
+ 'include_folders': None,
+ 'name': None,
+ 'password': None,
+ }),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'days': list([
+ ]),
+ 'recurrence': 'never',
+ 'time': None,
}),
}),
}),
@@ -1656,6 +2999,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1682,6 +3029,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1702,12 +3053,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -1717,13 +3075,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1750,6 +3110,10 @@
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1770,12 +3134,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -1785,13 +3156,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1823,12 +3196,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -1838,13 +3218,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1860,12 +3242,19 @@
dict({
'addons': list([
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 1,
+ }),
+ }),
'backup_id': 'def456',
'database_included': False,
'date': '1980-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'unknown_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -1875,13 +3264,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test 2',
- 'protected': False,
- 'size': 1,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1908,12 +3299,19 @@
dict({
'addons': list([
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 1,
+ }),
+ }),
'backup_id': 'def456',
'database_included': False,
'date': '1980-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'unknown_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -1923,13 +3321,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test 2',
- 'protected': False,
- 'size': 1,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -1950,13 +3350,23 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -1966,13 +3376,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2004,12 +3416,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2019,13 +3438,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2058,12 +3479,17 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'domain.test',
- ]),
+ 'agents': dict({
+ 'domain.test': dict({
+ 'protected': False,
+ 'size': 13,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00Z',
+ 'extra_metadata': dict({
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2073,13 +3499,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 13,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2112,12 +3540,17 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'domain.test',
- ]),
+ 'agents': dict({
+ 'domain.test': dict({
+ 'protected': False,
+ 'size': 13,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00Z',
+ 'extra_metadata': dict({
+ }),
'failed_agent_ids': list([
'test.remote',
]),
@@ -2128,13 +3561,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 13,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2166,12 +3601,17 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'domain.test',
- ]),
+ 'agents': dict({
+ 'domain.test': dict({
+ 'protected': False,
+ 'size': 13,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00Z',
+ 'extra_metadata': dict({
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2181,13 +3621,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 13,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2219,12 +3661,17 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'domain.test',
- ]),
+ 'agents': dict({
+ 'domain.test': dict({
+ 'protected': False,
+ 'size': 13,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00Z',
+ 'extra_metadata': dict({
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2234,13 +3681,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 13,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2272,12 +3721,17 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'domain.test',
- ]),
+ 'agents': dict({
+ 'domain.test': dict({
+ 'protected': False,
+ 'size': 13,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00Z',
+ 'extra_metadata': dict({
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2287,13 +3741,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 13,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2325,12 +3781,17 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'domain.test',
- ]),
+ 'agents': dict({
+ 'domain.test': dict({
+ 'protected': False,
+ 'size': 13,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00Z',
+ 'extra_metadata': dict({
+ }),
'failed_agent_ids': list([
'test.remote',
]),
@@ -2341,13 +3802,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 13,
'with_automatic_settings': None,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2379,12 +3842,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2394,8 +3864,6 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
}),
@@ -2417,12 +3885,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2432,8 +3907,6 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
}),
@@ -2467,13 +3940,23 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2483,8 +3966,6 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
}),
@@ -2507,12 +3988,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2522,8 +4010,6 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
}),
@@ -2563,6 +4049,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'in_progress',
}),
@@ -2584,6 +4071,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': 'home_assistant',
'state': 'in_progress',
}),
@@ -2595,6 +4083,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': 'upload_to_agents',
'state': 'in_progress',
}),
@@ -2606,6 +4095,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'completed',
}),
@@ -2634,6 +4124,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'in_progress',
}),
@@ -2655,6 +4146,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': 'home_assistant',
'state': 'in_progress',
}),
@@ -2666,6 +4158,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': 'upload_to_agents',
'state': 'in_progress',
}),
@@ -2677,6 +4170,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'completed',
}),
@@ -2705,6 +4199,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'in_progress',
}),
@@ -2726,6 +4221,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': 'home_assistant',
'state': 'in_progress',
}),
@@ -2737,6 +4233,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': 'upload_to_agents',
'state': 'in_progress',
}),
@@ -2748,6 +4245,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'completed',
}),
@@ -2770,12 +4268,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2785,13 +4290,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2812,12 +4319,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2827,13 +4341,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2854,13 +4370,23 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'test.remote',
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2870,13 +4396,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2892,12 +4420,19 @@
dict({
'addons': list([
]),
- 'agent_ids': list([
- 'test.remote',
- ]),
+ 'agents': dict({
+ 'test.remote': dict({
+ 'protected': False,
+ 'size': 1,
+ }),
+ }),
'backup_id': 'def456',
'database_included': False,
'date': '1980-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'unknown_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2907,8 +4442,6 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test 2',
- 'protected': False,
- 'size': 1,
'with_automatic_settings': None,
}),
dict({
@@ -2919,12 +4452,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2934,13 +4474,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -2962,12 +4504,19 @@
'version': '1.0.0',
}),
]),
- 'agent_ids': list([
- 'backup.local',
- ]),
+ 'agents': dict({
+ 'backup.local': dict({
+ 'protected': False,
+ 'size': 0,
+ }),
+ }),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
+ }),
'failed_agent_ids': list([
]),
'folders': list([
@@ -2977,13 +4526,15 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'protected': False,
- 'size': 0,
'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None,
+ 'last_non_idle_event': None,
+ 'next_automatic_backup': None,
+ 'next_automatic_backup_additional': False,
+ 'state': 'idle',
}),
'success': True,
'type': 'result',
@@ -3082,6 +4633,7 @@
dict({
'event': dict({
'manager_state': 'create_backup',
+ 'reason': None,
'stage': None,
'state': 'in_progress',
}),
diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py
index 02252ef6fa5..38b61ce65ea 100644
--- a/tests/components/backup/test_backup.py
+++ b/tests/components/backup/test_backup.py
@@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch
import pytest
from syrupy import SnapshotAssertion
-from homeassistant.components.backup import DOMAIN
+from homeassistant.components.backup import DOMAIN, AgentBackup
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123
+from .common import (
+ TEST_BACKUP_ABC123,
+ TEST_BACKUP_DEF456,
+ TEST_BACKUP_PATH_ABC123,
+ TEST_BACKUP_PATH_DEF456,
+)
from tests.typing import ClientSessionGenerator, WebSocketGenerator
+def mock_read_backup(backup_path: Path) -> AgentBackup:
+ """Mock read backup."""
+ mock_backups = {
+ "abc123": TEST_BACKUP_ABC123,
+ "custom_def456": TEST_BACKUP_DEF456,
+ }
+ return mock_backups[backup_path.stem]
+
+
@pytest.fixture(name="read_backup")
def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]:
"""Mock read backup."""
with patch(
"homeassistant.components.backup.backup.read_backup",
- return_value=TEST_BACKUP_ABC123,
+ side_effect=mock_read_backup,
) as read_backup:
yield read_backup
@@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]:
@pytest.mark.parametrize(
"side_effect",
[
- None,
+ mock_read_backup,
OSError("Boom"),
TarError("Boom"),
json.JSONDecodeError("Boom", "test", 1),
@@ -89,16 +103,26 @@ async def test_upload(
assert resp.status == 201
assert open_mock.call_count == 1
assert move_mock.call_count == 1
- assert move_mock.mock_calls[0].args[1].name == "abc123.tar"
+ assert move_mock.mock_calls[0].args[1].name == "Test_1970-01-01_00.00_00000000.tar"
@pytest.mark.usefixtures("read_backup")
@pytest.mark.parametrize(
- ("found_backups", "backup_exists", "unlink_calls"),
+ ("found_backups", "backup_id", "unlink_calls", "unlink_path"),
[
- ([TEST_BACKUP_PATH_ABC123], True, 1),
- ([TEST_BACKUP_PATH_ABC123], False, 0),
- (([], True, 0)),
+ (
+ [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456],
+ TEST_BACKUP_ABC123.backup_id,
+ 1,
+ TEST_BACKUP_PATH_ABC123,
+ ),
+ (
+ [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456],
+ TEST_BACKUP_DEF456.backup_id,
+ 1,
+ TEST_BACKUP_PATH_DEF456,
+ ),
+ (([], TEST_BACKUP_ABC123.backup_id, 0, None)),
],
)
async def test_delete_backup(
@@ -108,8 +132,9 @@ async def test_delete_backup(
snapshot: SnapshotAssertion,
path_glob: MagicMock,
found_backups: list[Path],
- backup_exists: bool,
+ backup_id: str,
unlink_calls: int,
+ unlink_path: Path | None,
) -> None:
"""Test delete backup."""
assert await async_setup_component(hass, DOMAIN, {})
@@ -118,12 +143,13 @@ async def test_delete_backup(
path_glob.return_value = found_backups
with (
- patch("pathlib.Path.exists", return_value=backup_exists),
- patch("pathlib.Path.unlink") as unlink,
+ patch("pathlib.Path.unlink", autospec=True) as unlink,
):
await client.send_json_auto_id(
- {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id}
+ {"type": "backup/delete", "backup_id": backup_id}
)
assert await client.receive_json() == snapshot
assert unlink.call_count == unlink_calls
+ for call in unlink.mock_calls:
+ assert call.args[0] == unlink_path
diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py
index c071a0d8386..24fd15fc4fe 100644
--- a/tests/components/backup/test_http.py
+++ b/tests/components/backup/test_http.py
@@ -1,18 +1,34 @@
"""Tests for the Backup integration."""
import asyncio
-from io import StringIO
+from collections.abc import AsyncIterator
+from io import BytesIO, StringIO
+import json
+import tarfile
+from typing import Any
from unittest.mock import patch
from aiohttp import web
import pytest
-from homeassistant.components.backup.const import DATA_MANAGER
+from homeassistant.components.backup import (
+ AddonInfo,
+ AgentBackup,
+ BackupAgentError,
+ BackupNotFound,
+ Folder,
+)
+from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
from homeassistant.core import HomeAssistant
-from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration
+from .common import (
+ TEST_BACKUP_ABC123,
+ BackupAgentTest,
+ aiter_from_iter,
+ setup_backup_integration,
+)
-from tests.common import MockUser
+from tests.common import MockUser, get_fixture_path
from tests.typing import ClientSessionGenerator
@@ -30,6 +46,9 @@ async def test_downloading_local_backup(
"homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup",
return_value=TEST_BACKUP_ABC123,
),
+ patch(
+ "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path",
+ ),
patch("pathlib.Path.exists", return_value=True),
patch(
"homeassistant.components.backup.http.FileResponse",
@@ -45,8 +64,9 @@ async def test_downloading_remote_backup(
hass_client: ClientSessionGenerator,
) -> None:
"""Test downloading a remote backup."""
- await setup_backup_integration(hass)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+ await setup_backup_integration(
+ hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"]
+ )
client = await hass_client()
@@ -54,11 +74,183 @@ async def test_downloading_remote_backup(
patch.object(BackupAgentTest, "async_download_backup") as download_mock,
):
download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
- resp = await client.get("/api/backup/download/abc123?agent_id=domain.test")
+ resp = await client.get("/api/backup/download/abc123?agent_id=test.test")
assert resp.status == 200
assert await resp.content.read() == b"backup data"
+async def test_downloading_local_encrypted_backup_file_not_found(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test downloading a local backup file."""
+ await setup_backup_integration(hass)
+ client = await hass_client()
+
+ with (
+ patch(
+ "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup",
+ return_value=TEST_BACKUP_ABC123,
+ ),
+ patch(
+ "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path",
+ ),
+ ):
+ resp = await client.get(
+ "/api/backup/download/abc123?agent_id=backup.local&password=blah"
+ )
+ assert resp.status == 404
+
+
+@pytest.mark.usefixtures("mock_backups")
+async def test_downloading_local_encrypted_backup(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test downloading a local backup file."""
+ await setup_backup_integration(hass)
+ await _test_downloading_encrypted_backup(hass_client, "backup.local")
+
+
+@patch.object(BackupAgentTest, "async_download_backup")
+async def test_downloading_remote_encrypted_backup(
+ download_mock,
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test downloading a local backup file."""
+ backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ await setup_backup_integration(hass)
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest(
+ "test",
+ [
+ AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id="c0cb53bd",
+ database_included=True,
+ date="1970-01-01T00:00:00Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=13,
+ )
+ ],
+ )
+
+ async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]:
+ return aiter_from_iter((backup_path.read_bytes(),))
+
+ download_mock.side_effect = download_backup
+ await _test_downloading_encrypted_backup(hass_client, "domain.test")
+
+
+@pytest.mark.parametrize(
+ ("error", "status"),
+ [
+ (BackupAgentError, 500),
+ (BackupNotFound, 404),
+ ],
+)
+@patch.object(BackupAgentTest, "async_download_backup")
+async def test_downloading_remote_encrypted_backup_with_error(
+ download_mock,
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ error: Exception,
+ status: int,
+) -> None:
+ """Test downloading a local backup file."""
+ await setup_backup_integration(hass)
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest(
+ "test",
+ [
+ AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id="abc123",
+ database_included=True,
+ date="1970-01-01T00:00:00Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=13,
+ )
+ ],
+ )
+
+ download_mock.side_effect = error
+ client = await hass_client()
+ resp = await client.get(
+ "/api/backup/download/abc123?agent_id=domain.test&password=blah"
+ )
+ assert resp.status == status
+
+
+async def _test_downloading_encrypted_backup(
+ hass_client: ClientSessionGenerator,
+ agent_id: str,
+) -> None:
+ """Test downloading an encrypted backup file."""
+ # Try downloading without supplying a password
+ client = await hass_client()
+ resp = await client.get(f"/api/backup/download/c0cb53bd?agent_id={agent_id}")
+ assert resp.status == 200
+ backup = await resp.read()
+ # We expect a valid outer tar file, but the inner tar file is encrypted and
+ # can't be read
+ with tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar:
+ enc_metadata = json.loads(outer_tar.extractfile("./backup.json").read())
+ assert enc_metadata["protected"] is True
+ with (
+ outer_tar.extractfile("core.tar.gz") as inner_tar_file,
+ pytest.raises(tarfile.ReadError, match="file could not be opened"),
+ ):
+ # pylint: disable-next=consider-using-with
+ tarfile.open(fileobj=inner_tar_file, mode="r")
+
+ # Download with the wrong password
+ resp = await client.get(
+ f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=wrong"
+ )
+ assert resp.status == 200
+ backup = await resp.read()
+ # We expect a truncated outer tar file
+ with (
+ tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar,
+ pytest.raises(tarfile.ReadError, match="unexpected end of data"),
+ ):
+ outer_tar.getnames()
+
+ # Finally download with the correct password
+ resp = await client.get(
+ f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=hunter2"
+ )
+ assert resp.status == 200
+ backup = await resp.read()
+ # We expect a valid outer tar file, the inner tar file is decrypted and can be read
+ with (
+ tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar,
+ ):
+ dec_metadata = json.loads(outer_tar.extractfile("./backup.json").read())
+ assert dec_metadata == enc_metadata | {"protected": False}
+ with (
+ outer_tar.extractfile("core.tar.gz") as inner_tar_file,
+ tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar,
+ ):
+ assert inner_tar.getnames() == [
+ ".",
+ "README.md",
+ "test_symlink",
+ "test1",
+ "test1/script.sh",
+ ]
+
+
async def test_downloading_backup_not_found(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -98,12 +290,14 @@ async def test_uploading_a_backup_file(
with patch(
"homeassistant.components.backup.manager.BackupManager.async_receive_backup",
+ return_value=TEST_BACKUP_ABC123.backup_id,
) as async_receive_backup_mock:
resp = await client.post(
"/api/backup/upload?agent_id=backup.local",
data={"file": StringIO("test")},
)
assert resp.status == 201
+ assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id}
assert async_receive_backup_mock.called
diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py
index 16a49af9647..925e2cb9b7a 100644
--- a/tests/components/backup/test_init.py
+++ b/tests/components/backup/test_init.py
@@ -11,6 +11,8 @@ from homeassistant.exceptions import ServiceNotFound
from .common import setup_backup_integration
+from tests.typing import WebSocketGenerator
+
@pytest.mark.usefixtures("supervisor_client")
async def test_setup_with_hassio(
@@ -45,7 +47,16 @@ async def test_create_service(
service_data=service_data,
)
- assert generate_backup.called
+ generate_backup.assert_called_once_with(
+ agent_ids=["backup.local"],
+ include_addons=None,
+ include_all_addons=False,
+ include_database=True,
+ include_folders=None,
+ include_homeassistant=True,
+ name=None,
+ password=None,
+ )
async def test_create_service_with_hassio(hass: HomeAssistant) -> None:
@@ -54,3 +65,82 @@ async def test_create_service_with_hassio(hass: HomeAssistant) -> None:
with pytest.raises(ServiceNotFound):
await hass.services.async_call(DOMAIN, "create", blocking=True)
+
+
+@pytest.mark.parametrize(
+ ("commands", "expected_kwargs"),
+ [
+ (
+ [],
+ {
+ "agent_ids": [],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "name": None,
+ "password": None,
+ "with_automatic_settings": True,
+ },
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["my-addon"],
+ "include_all_addons": True,
+ "include_database": False,
+ "include_folders": ["share"],
+ "name": "cool_backup",
+ "password": "hunter2",
+ },
+ },
+ ],
+ {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["my-addon"],
+ "include_all_addons": True,
+ "include_database": False,
+ "include_folders": ["share"],
+ "include_homeassistant": True,
+ "name": "cool_backup",
+ "password": "hunter2",
+ "with_automatic_settings": True,
+ },
+ ),
+ ],
+)
+@pytest.mark.parametrize("service_data", [None, {}])
+@pytest.mark.parametrize("with_hassio", [True, False])
+@pytest.mark.usefixtures("supervisor_client")
+async def test_create_automatic_service(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ commands: list[dict[str, Any]],
+ expected_kwargs: dict[str, Any],
+ service_data: dict[str, Any] | None,
+ with_hassio: bool,
+) -> None:
+ """Test generate backup."""
+ await setup_backup_integration(hass, with_hassio=with_hassio)
+
+ client = await hass_ws_client(hass)
+ for command in commands:
+ await client.send_json_auto_id(command)
+ result = await client.receive_json()
+ assert result["success"]
+
+ with patch(
+ "homeassistant.components.backup.manager.BackupManager.async_create_backup",
+ ) as generate_backup:
+ await hass.services.async_call(
+ DOMAIN,
+ "create_automatic",
+ blocking=True,
+ service_data=service_data,
+ )
+
+ generate_backup.assert_called_once_with(**expected_kwargs)
diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py
index ad90e2e23bf..bdcb9f068b6 100644
--- a/tests/components/backup/test_manager.py
+++ b/tests/components/backup/test_manager.py
@@ -8,16 +8,26 @@ from dataclasses import replace
from io import StringIO
import json
from pathlib import Path
+import tarfile
from typing import Any
-from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch
+from unittest.mock import (
+ ANY,
+ DEFAULT,
+ AsyncMock,
+ MagicMock,
+ Mock,
+ call,
+ mock_open,
+ patch,
+)
+from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.backup import (
DOMAIN,
AgentBackup,
BackupAgentPlatformProtocol,
- BackupManager,
BackupReaderWriterError,
Folder,
LocalBackupAgent,
@@ -28,13 +38,15 @@ from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import (
BackupManagerError,
BackupManagerState,
- CoreBackupReaderWriter,
- CreateBackupEvent,
CreateBackupStage,
CreateBackupState,
NewBackup,
+ ReceiveBackupStage,
+ ReceiveBackupState,
+ RestoreBackupState,
WrittenBackup,
)
+from homeassistant.components.backup.util import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
@@ -44,6 +56,8 @@ from .common import (
LOCAL_AGENT_ID,
TEST_BACKUP_ABC123,
TEST_BACKUP_DEF456,
+ TEST_BACKUP_PATH_ABC123,
+ TEST_BACKUP_PATH_DEF456,
BackupAgentTest,
setup_backup_platform,
)
@@ -79,14 +93,23 @@ def generate_backup_id_fixture() -> Generator[MagicMock]:
yield mock
+def mock_read_backup(backup_path: Path) -> AgentBackup:
+ """Mock read backup."""
+ mock_backups = {
+ "abc123": TEST_BACKUP_ABC123,
+ "custom_def456": TEST_BACKUP_DEF456,
+ }
+ return mock_backups[backup_path.stem]
+
+
@pytest.mark.usefixtures("mock_backup_generation")
-async def test_async_create_backup(
+async def test_create_backup_service(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
- """Test create backup."""
+ """Test create backup service."""
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
@@ -114,7 +137,7 @@ async def test_async_create_backup(
agent_ids=["backup.local"],
backup_name="Custom backup 2025.1.0",
extra_metadata={
- "instance_id": hass.data["core.uuid"],
+ "instance_id": "our_uuid",
"with_automatic_settings": False,
},
include_addons=None,
@@ -127,30 +150,224 @@ async def test_async_create_backup(
)
-async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
- """Test generate backup."""
- manager = BackupManager(hass, CoreBackupReaderWriter(hass))
- manager.last_event = CreateBackupEvent(
- stage=None, state=CreateBackupState.IN_PROGRESS
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ ("manager_kwargs", "expected_writer_kwargs"),
+ [
+ (
+ {
+ "agent_ids": ["backup.local"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "name": None,
+ "password": None,
+ },
+ {
+ "agent_ids": ["backup.local"],
+ "backup_name": "Custom backup 2025.1.0",
+ "extra_metadata": {
+ "instance_id": ANY,
+ "with_automatic_settings": False,
+ },
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "on_progress": ANY,
+ "password": None,
+ },
+ ),
+ (
+ {
+ "agent_ids": ["backup.local"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "name": None,
+ "password": None,
+ "with_automatic_settings": True,
+ },
+ {
+ "agent_ids": ["backup.local"],
+ "backup_name": "Automatic backup 2025.1.0",
+ "extra_metadata": {
+ "instance_id": ANY,
+ "with_automatic_settings": True,
+ },
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "on_progress": ANY,
+ "password": None,
+ },
+ ),
+ (
+ {
+ "agent_ids": ["backup.local"],
+ "extra_metadata": {"custom": "data"},
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "name": None,
+ "password": None,
+ },
+ {
+ "agent_ids": ["backup.local"],
+ "backup_name": "Custom backup 2025.1.0",
+ "extra_metadata": {
+ "custom": "data",
+ "instance_id": ANY,
+ "with_automatic_settings": False,
+ },
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "on_progress": ANY,
+ "password": None,
+ },
+ ),
+ (
+ {
+ "agent_ids": ["backup.local"],
+ "extra_metadata": {"custom": "data"},
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "name": "user defined name",
+ "password": None,
+ },
+ {
+ "agent_ids": ["backup.local"],
+ "backup_name": "user defined name",
+ "extra_metadata": {
+ "custom": "data",
+ "instance_id": ANY,
+ "with_automatic_settings": False,
+ },
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "on_progress": ANY,
+ "password": None,
+ },
+ ),
+ (
+ {
+ "agent_ids": ["backup.local"],
+ "extra_metadata": {"custom": "data"},
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "name": " ", # Name which is just whitespace
+ "password": None,
+ },
+ {
+ "agent_ids": ["backup.local"],
+ "backup_name": "Custom backup 2025.1.0",
+ "extra_metadata": {
+ "custom": "data",
+ "instance_id": ANY,
+ "with_automatic_settings": False,
+ },
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "include_homeassistant": True,
+ "on_progress": ANY,
+ "password": None,
+ },
+ ),
+ ],
+)
+async def test_async_create_backup(
+ hass: HomeAssistant,
+ caplog: pytest.LogCaptureFixture,
+ mocked_json_bytes: Mock,
+ mocked_tarfile: Mock,
+ manager_kwargs: dict[str, Any],
+ expected_writer_kwargs: dict[str, Any],
+) -> None:
+ """Test create backup."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ manager = hass.data[DATA_MANAGER]
+
+ new_backup = NewBackup(backup_job_id="time-123")
+ backup_task = AsyncMock(
+ return_value=WrittenBackup(
+ backup=TEST_BACKUP_ABC123,
+ open_stream=AsyncMock(),
+ release_stream=AsyncMock(),
+ ),
+ )() # call it so that it can be awaited
+
+ with patch(
+ "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup",
+ return_value=(new_backup, backup_task),
+ ) as create_backup:
+ await manager.async_create_backup(**manager_kwargs)
+
+ assert create_backup.called
+ assert create_backup.call_args == call(**expected_writer_kwargs)
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+async def test_create_backup_when_busy(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test generate backup with busy manager."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]}
)
- with pytest.raises(HomeAssistantError, match="Backup manager busy"):
- await manager.async_create_backup(
- agent_ids=[LOCAL_AGENT_ID],
- include_addons=[],
- include_all_addons=False,
- include_database=True,
- include_folders=[],
- include_homeassistant=True,
- name=None,
- password=None,
- )
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]}
+ )
+ result = await ws_client.receive_json()
+
+ assert result["success"] is False
+ assert result["error"]["code"] == "home_assistant_error"
+ assert result["error"]["message"] == "Backup manager busy: create_backup"
@pytest.mark.parametrize(
("parameters", "expected_error"),
[
- ({"agent_ids": []}, "At least one agent must be selected"),
- ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"),
+ (
+ {"agent_ids": []},
+ "At least one available backup agent must be selected, got []",
+ ),
+ (
+ {"agent_ids": ["non_existing"]},
+ "At least one available backup agent must be selected, got ['non_existing']",
+ ),
(
{"include_addons": ["ssl"], "include_all_addons": True},
"Cannot include all addons and specify specific addons",
@@ -194,26 +411,103 @@ async def test_create_backup_wrong_parameters(
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
- ("agent_ids", "backup_directory", "temp_file_unlink_call_count"),
+ (
+ "agent_ids",
+ "backup_directory",
+ "name",
+ "expected_name",
+ "expected_filename",
+ "expected_agent_ids",
+ "expected_failed_agent_ids",
+ "temp_file_unlink_call_count",
+ ),
[
- ([LOCAL_AGENT_ID], "backups", 0),
- (["test.remote"], "tmp_backups", 1),
- ([LOCAL_AGENT_ID, "test.remote"], "backups", 0),
+ (
+ [LOCAL_AGENT_ID],
+ "backups",
+ None,
+ "Custom backup 2025.1.0",
+ "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar",
+ [LOCAL_AGENT_ID],
+ [],
+ 0,
+ ),
+ (
+ ["test.remote"],
+ "tmp_backups",
+ None,
+ "Custom backup 2025.1.0",
+ "abc123.tar", # We don't use friendly name for temporary backups
+ ["test.remote"],
+ [],
+ 1,
+ ),
+ (
+ [LOCAL_AGENT_ID, "test.remote"],
+ "backups",
+ None,
+ "Custom backup 2025.1.0",
+ "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar",
+ [LOCAL_AGENT_ID, "test.remote"],
+ [],
+ 0,
+ ),
+ (
+ [LOCAL_AGENT_ID],
+ "backups",
+ "custom_name",
+ "custom_name",
+ "custom_name_2025-01-30_05.42_12345678.tar",
+ [LOCAL_AGENT_ID],
+ [],
+ 0,
+ ),
+ (
+ ["test.remote"],
+ "tmp_backups",
+ "custom_name",
+ "custom_name",
+ "abc123.tar", # We don't use friendly name for temporary backups
+ ["test.remote"],
+ [],
+ 1,
+ ),
+ (
+ [LOCAL_AGENT_ID, "test.remote"],
+ "backups",
+ "custom_name",
+ "custom_name",
+ "custom_name_2025-01-30_05.42_12345678.tar",
+ [LOCAL_AGENT_ID, "test.remote"],
+ [],
+ 0,
+ ),
+ (
+ # Test we create a backup when at least one agent is available
+ [LOCAL_AGENT_ID, "test.unavailable"],
+ "backups",
+ "custom_name",
+ "custom_name",
+ "custom_name_2025-01-30_05.42_12345678.tar",
+ [LOCAL_AGENT_ID],
+ ["test.unavailable"],
+ 0,
+ ),
],
)
@pytest.mark.parametrize(
"params",
[
{},
- {"include_database": True, "name": "abc123"},
+ {"include_database": True},
{"include_database": False},
{"password": "pass123"},
],
)
-async def test_async_initiate_backup(
+async def test_initiate_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
- caplog: pytest.LogCaptureFixture,
+ freezer: FrozenDateTimeFactory,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
generate_backup_id: MagicMock,
@@ -221,15 +515,17 @@ async def test_async_initiate_backup(
params: dict[str, Any],
agent_ids: list[str],
backup_directory: str,
+ name: str | None,
+ expected_name: str,
+ expected_filename: str,
+ expected_agent_ids: list[str],
+ expected_failed_agent_ids: list[str],
temp_file_unlink_call_count: int,
) -> None:
"""Test generate backup."""
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
- agents = {
- f"backup.{local_agent.name}": local_agent,
- f"test.{remote_agent.name}": remote_agent,
- }
+
with patch(
"homeassistant.components.backup.backup.async_get_backup_agents"
) as core_get_backup_agents:
@@ -246,9 +542,9 @@ async def test_async_initiate_backup(
)
ws_client = await hass_ws_client(hass)
+ freezer.move_to("2025-01-30 13:42:12.345678")
include_database = params.get("include_database", True)
- name = params.get("name", "Custom backup 2025.1.0")
password = params.get("password")
path_glob.return_value = []
@@ -261,6 +557,10 @@ async def test_async_initiate_backup(
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@@ -276,11 +576,12 @@ async def test_async_initiate_backup(
patch("pathlib.Path.unlink") as unlink_mock,
):
await ws_client.send_json_auto_id(
- {"type": "backup/generate", "agent_ids": agent_ids} | params
+ {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params
)
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -295,6 +596,7 @@ async def test_async_initiate_backup(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -302,6 +604,7 @@ async def test_async_initiate_backup(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -309,6 +612,7 @@ async def test_async_initiate_backup(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": None,
"state": CreateBackupState.COMPLETED,
}
@@ -325,16 +629,16 @@ async def test_async_initiate_backup(
"compressed": True,
"date": ANY,
"extra": {
- "instance_id": hass.data["core.uuid"],
+ "instance_id": "our_uuid",
"with_automatic_settings": False,
},
"homeassistant": {
"exclude_database": not include_database,
"version": "2025.1.0",
},
- "name": name,
+ "name": expected_name,
"protected": bool(password),
- "slug": ANY,
+ "slug": backup_id,
"type": "partial",
"version": 2,
}
@@ -345,34 +649,25 @@ async def test_async_initiate_backup(
result = await ws_client.receive_json()
backup_data = result["result"]["backup"]
- backup_agent_ids = backup_data.pop("agent_ids")
- assert backup_agent_ids == agent_ids
assert backup_data == {
"addons": [],
- "backup_id": ANY,
+ "agents": {
+ agent_id: {"protected": bool(password), "size": ANY}
+ for agent_id in expected_agent_ids
+ },
+ "backup_id": backup_id,
"database_included": include_database,
"date": ANY,
- "failed_agent_ids": [],
+ "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False},
+ "failed_agent_ids": expected_failed_agent_ids,
"folders": [],
"homeassistant_included": True,
"homeassistant_version": "2025.1.0",
- "name": name,
- "protected": bool(password),
- "size": ANY,
+ "name": expected_name,
"with_automatic_settings": False,
}
- for agent_id in agent_ids:
- agent = agents[agent_id]
- assert len(agent._backups) == 1
- agent_backup = agent._backups[backup_data["backup_id"]]
- assert agent_backup.backup_id == backup_data["backup_id"]
- assert agent_backup.date == backup_data["date"]
- assert agent_backup.name == backup_data["name"]
- assert agent_backup.protected == backup_data["protected"]
- assert agent_backup.size == backup_data["size"]
-
outer_tar = mocked_tarfile.return_value
core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value
expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [
@@ -383,12 +678,12 @@ async def test_async_initiate_backup(
tar_file_path = str(mocked_tarfile.call_args_list[0][0][0])
backup_directory = hass.config.path(backup_directory)
- assert tar_file_path == f"{backup_directory}/{backup_data["backup_id"]}.tar"
+ assert tar_file_path == f"{backup_directory}/{expected_filename}"
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")])
-async def test_async_initiate_backup_with_agent_error(
+async def test_initiate_backup_with_agent_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
@@ -411,12 +706,14 @@ async def test_async_initiate_backup_with_agent_error(
"version": "1.0.0",
},
],
- "agent_ids": [
- "test.remote",
- ],
+ "agents": {"test.remote": {"protected": False, "size": 0}},
"backup_id": "backup1",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
+ "extra_metadata": {
+ "instance_id": "our_uuid",
+ "with_automatic_settings": True,
+ },
"failed_agent_ids": [],
"folders": [
"media",
@@ -425,18 +722,18 @@ async def test_async_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
- "protected": False,
- "size": 0,
"with_automatic_settings": True,
},
{
"addons": [],
- "agent_ids": [
- "test.remote",
- ],
+ "agents": {"test.remote": {"protected": False, "size": 1}},
"backup_id": "backup2",
"database_included": False,
"date": "1980-01-01T00:00:00.000Z",
+ "extra_metadata": {
+ "instance_id": "unknown_uuid",
+ "with_automatic_settings": True,
+ },
"failed_agent_ids": [],
"folders": [
"media",
@@ -445,8 +742,6 @@ async def test_async_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test 2",
- "protected": False,
- "size": 1,
"with_automatic_settings": None,
},
{
@@ -457,12 +752,14 @@ async def test_async_initiate_backup_with_agent_error(
"version": "1.0.0",
},
],
- "agent_ids": [
- "test.remote",
- ],
+ "agents": {"test.remote": {"protected": False, "size": 0}},
"backup_id": "backup3",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
+ "extra_metadata": {
+ "instance_id": "our_uuid",
+ "with_automatic_settings": True,
+ },
"failed_agent_ids": [],
"folders": [
"media",
@@ -471,8 +768,6 @@ async def test_async_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
- "protected": False,
- "size": 0,
"with_automatic_settings": True,
},
]
@@ -506,6 +801,10 @@ async def test_async_initiate_backup_with_agent_error(
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
await ws_client.send_json_auto_id(
@@ -540,6 +839,7 @@ async def test_async_initiate_backup_with_agent_error(
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
+ "reason": None,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
@@ -553,6 +853,7 @@ async def test_async_initiate_backup_with_agent_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -560,6 +861,7 @@ async def test_async_initiate_backup_with_agent_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -567,6 +869,7 @@ async def test_async_initiate_backup_with_agent_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": "upload_failed",
"stage": None,
"state": CreateBackupState.FAILED,
}
@@ -576,17 +879,16 @@ async def test_async_initiate_backup_with_agent_error(
new_expected_backup_data = {
"addons": [],
- "agent_ids": ["backup.local"],
+ "agents": {"backup.local": {"protected": False, "size": 123}},
"backup_id": "abc123",
"database_included": True,
"date": ANY,
+ "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False},
"failed_agent_ids": ["test.remote"],
"folders": [],
"homeassistant_included": True,
"homeassistant_version": "2025.1.0",
"name": "Custom backup 2025.1.0",
- "protected": False,
- "size": 123,
"with_automatic_settings": False,
}
@@ -600,6 +902,15 @@ async def test_async_initiate_backup_with_agent_error(
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": {
+ "manager_state": "create_backup",
+ "reason": "upload_failed",
+ "stage": None,
+ "state": "failed",
+ },
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
await hass.async_block_till_done()
@@ -670,7 +981,7 @@ async def test_create_backup_success_clears_issue(
assert set(issue_registry.issues) == issues_after_create_backup
-async def delayed_boom(*args, **kwargs) -> None:
+async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
"""Raise an exception after a delay."""
async def delayed_boom() -> None:
@@ -682,6 +993,7 @@ async def delayed_boom(*args, **kwargs) -> None:
@pytest.mark.parametrize(
(
+ "automatic_agents",
"create_backup_command",
"create_backup_side_effect",
"agent_upload_side_effect",
@@ -691,6 +1003,7 @@ async def delayed_boom(*args, **kwargs) -> None:
[
# No error
(
+ ["test.remote"],
{"type": "backup/generate", "agent_ids": ["test.remote"]},
None,
None,
@@ -698,14 +1011,38 @@ async def delayed_boom(*args, **kwargs) -> None:
{},
),
(
+ ["test.remote"],
{"type": "backup/generate_with_automatic_settings"},
None,
None,
True,
{},
),
+ # One agent unavailable
+ (
+ ["test.remote", "test.unknown"],
+ {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]},
+ None,
+ None,
+ True,
+ {},
+ ),
+ (
+ ["test.remote", "test.unknown"],
+ {"type": "backup/generate_with_automatic_settings"},
+ None,
+ None,
+ True,
+ {
+ (DOMAIN, "automatic_backup_failed"): {
+ "translation_key": "automatic_backup_failed_upload_agents",
+ "translation_placeholders": {"failed_agents": "test.unknown"},
+ }
+ },
+ ),
# Error raised in async_initiate_backup
(
+ ["test.remote"],
{"type": "backup/generate", "agent_ids": ["test.remote"]},
Exception("Boom!"),
None,
@@ -713,6 +1050,7 @@ async def delayed_boom(*args, **kwargs) -> None:
{},
),
(
+ ["test.remote"],
{"type": "backup/generate_with_automatic_settings"},
Exception("Boom!"),
None,
@@ -726,6 +1064,7 @@ async def delayed_boom(*args, **kwargs) -> None:
),
# Error raised when awaiting the backup task
(
+ ["test.remote"],
{"type": "backup/generate", "agent_ids": ["test.remote"]},
delayed_boom,
None,
@@ -733,6 +1072,7 @@ async def delayed_boom(*args, **kwargs) -> None:
{},
),
(
+ ["test.remote"],
{"type": "backup/generate_with_automatic_settings"},
delayed_boom,
None,
@@ -746,6 +1086,7 @@ async def delayed_boom(*args, **kwargs) -> None:
),
# Error raised in async_upload_backup
(
+ ["test.remote"],
{"type": "backup/generate", "agent_ids": ["test.remote"]},
None,
Exception("Boom!"),
@@ -753,6 +1094,7 @@ async def delayed_boom(*args, **kwargs) -> None:
{},
),
(
+ ["test.remote"],
{"type": "backup/generate_with_automatic_settings"},
None,
Exception("Boom!"),
@@ -760,7 +1102,7 @@ async def delayed_boom(*args, **kwargs) -> None:
{
(DOMAIN, "automatic_backup_failed"): {
"translation_key": "automatic_backup_failed_upload_agents",
- "translation_placeholders": {"failed_agents": "test.remote"},
+ "translation_placeholders": {"failed_agents": "remote"},
}
},
),
@@ -770,6 +1112,7 @@ async def test_create_backup_failure_raises_issue(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
create_backup: AsyncMock,
+ automatic_agents: list[str],
create_backup_command: dict[str, Any],
create_backup_side_effect: Exception | None,
agent_upload_side_effect: Exception | None,
@@ -800,7 +1143,7 @@ async def test_create_backup_failure_raises_issue(
await ws_client.send_json_auto_id(
{
"type": "backup/config/update",
- "create_backup": {"agent_ids": ["test.remote"]},
+ "create_backup": {"agent_ids": automatic_agents},
}
)
result = await ws_client.receive_json()
@@ -826,7 +1169,7 @@ async def test_create_backup_failure_raises_issue(
@pytest.mark.parametrize(
"exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")]
)
-async def test_async_initiate_backup_non_agent_upload_error(
+async def test_initiate_backup_non_agent_upload_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
@@ -835,11 +1178,6 @@ async def test_async_initiate_backup_non_agent_upload_error(
exception: Exception,
) -> None:
"""Test an unknown or writer upload error during backup generation."""
- hass_storage[DOMAIN] = {
- "data": {},
- "key": DOMAIN,
- "version": 1,
- }
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
@@ -872,6 +1210,10 @@ async def test_async_initiate_backup_non_agent_upload_error(
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@@ -896,6 +1238,7 @@ async def test_async_initiate_backup_non_agent_upload_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -910,6 +1253,7 @@ async def test_async_initiate_backup_non_agent_upload_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -917,6 +1261,7 @@ async def test_async_initiate_backup_non_agent_upload_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -924,6 +1269,7 @@ async def test_async_initiate_backup_non_agent_upload_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": "upload_failed",
"stage": None,
"state": CreateBackupState.FAILED,
}
@@ -931,14 +1277,14 @@ async def test_async_initiate_backup_non_agent_upload_error(
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
- assert not hass_storage[DOMAIN]["data"]
+ assert DOMAIN not in hass_storage
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
"exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")]
)
-async def test_async_initiate_backup_with_task_error(
+async def test_initiate_backup_with_task_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
@@ -982,6 +1328,10 @@ async def test_async_initiate_backup_with_task_error(
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@@ -1000,6 +1350,7 @@ async def test_async_initiate_backup_with_task_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -1007,6 +1358,7 @@ async def test_async_initiate_backup_with_task_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": "upload_failed",
"stage": None,
"state": CreateBackupState.FAILED,
}
@@ -1086,6 +1438,10 @@ async def test_initiate_backup_file_error(
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@@ -1112,6 +1468,7 @@ async def test_initiate_backup_file_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -1126,6 +1483,7 @@ async def test_initiate_backup_file_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -1133,6 +1491,7 @@ async def test_initiate_backup_file_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
@@ -1140,6 +1499,7 @@ async def test_initiate_backup_file_error(
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": "upload_failed",
"stage": None,
"state": CreateBackupState.FAILED,
}
@@ -1153,40 +1513,15 @@ async def test_initiate_backup_file_error(
assert unlink_mock.call_count == unlink_call_count
-async def test_loading_platforms(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- """Test loading backup platforms."""
- manager = BackupManager(hass, CoreBackupReaderWriter(hass))
-
- assert not manager.platforms
-
- get_agents_mock = AsyncMock(return_value=[])
-
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_pre_backup=AsyncMock(),
- async_post_backup=AsyncMock(),
- async_get_backup_agents=get_agents_mock,
- ),
- )
- await manager.load_platforms()
- await hass.async_block_till_done()
-
- assert len(manager.platforms) == 1
- assert "Loaded 1 platforms" in caplog.text
-
- get_agents_mock.assert_called_once_with(hass)
-
-
class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent):
"""Local backup agent."""
def get_backup_path(self, backup_id: str) -> Path:
- """Return the local path to a backup."""
+ """Return the local path to an existing backup."""
+ return Path("test.tar")
+
+ def get_new_backup_path(self, backup: AgentBackup) -> Path:
+ """Return the local path to a new backup."""
return Path("test.tar")
@@ -1221,8 +1556,8 @@ async def test_loading_platform_with_listener(
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
resp = await ws_client.receive_json()
assert resp["result"]["agents"] == [
- {"agent_id": "backup.local"},
- {"agent_id": "test.remote1"},
+ {"agent_id": "backup.local", "name": "local"},
+ {"agent_id": "test.remote1", "name": "remote1"},
]
assert len(manager.local_backup_agents) == num_local_agents
@@ -1238,8 +1573,8 @@ async def test_loading_platform_with_listener(
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
resp = await ws_client.receive_json()
assert resp["result"]["agents"] == [
- {"agent_id": "backup.local"},
- {"agent_id": "test.remote2"},
+ {"agent_id": "backup.local", "name": "local"},
+ {"agent_id": "test.remote2", "name": "remote2"},
]
assert len(manager.local_backup_agents) == num_local_agents
@@ -1342,7 +1677,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None:
"agent_id=backup.local&agent_id=test.remote",
2,
1,
- ["abc123.tar"],
+ ["Test_1970-01-01_00.00_00000000.tar"],
{TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123},
b"test",
0,
@@ -1351,7 +1686,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None:
"agent_id=backup.local",
1,
1,
- ["abc123.tar"],
+ ["Test_1970-01-01_00.00_00000000.tar"],
{},
None,
0,
@@ -1429,8 +1764,12 @@ async def test_receive_backup_busy_manager(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
+ create_backup: AsyncMock,
) -> None:
"""Test receive backup with a busy manager."""
+ new_backup = NewBackup(backup_job_id="time-123")
+ backup_task: asyncio.Future[WrittenBackup] = asyncio.Future()
+ create_backup.return_value = (new_backup, backup_task)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -1445,24 +1784,19 @@ async def test_receive_backup_busy_manager(
result = await ws_client.receive_json()
assert result["success"] is True
- new_backup = NewBackup(backup_job_id="time-123")
- backup_task: asyncio.Future[WrittenBackup] = asyncio.Future()
- with patch(
- "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup",
- return_value=(new_backup, backup_task),
- ) as create_backup:
- await ws_client.send_json_auto_id(
- {"type": "backup/generate", "agent_ids": ["backup.local"]}
- )
- result = await ws_client.receive_json()
- assert result["event"] == {
- "manager_state": "create_backup",
- "stage": None,
- "state": "in_progress",
- }
- result = await ws_client.receive_json()
- assert result["success"] is True
- assert result["result"] == {"backup_job_id": "time-123"}
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": ["backup.local"]}
+ )
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": "create_backup",
+ "reason": None,
+ "stage": None,
+ "state": "in_progress",
+ }
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+ assert result["result"] == {"backup_job_id": "time-123"}
assert create_backup.call_count == 1
@@ -1488,42 +1822,859 @@ async def test_receive_backup_busy_manager(
await hass.async_block_till_done()
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")])
+async def test_receive_backup_agent_error(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_ws_client: WebSocketGenerator,
+ path_glob: MagicMock,
+ hass_storage: dict[str, Any],
+ exception: Exception,
+) -> None:
+ """Test upload error during backup receive."""
+ local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
+ backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id
+ backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id
+ backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id
+ backups_info: list[dict[str, Any]] = [
+ {
+ "addons": [
+ {
+ "name": "Test",
+ "slug": "test",
+ "version": "1.0.0",
+ },
+ ],
+ "agents": {"test.remote": {"protected": False, "size": 0}},
+ "backup_id": "backup1",
+ "database_included": True,
+ "date": "1970-01-01T00:00:00.000Z",
+ "extra_metadata": {
+ "instance_id": "our_uuid",
+ "with_automatic_settings": True,
+ },
+ "failed_agent_ids": [],
+ "folders": [
+ "media",
+ "share",
+ ],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0",
+ "name": "Test",
+ "with_automatic_settings": True,
+ },
+ {
+ "addons": [],
+ "agents": {"test.remote": {"protected": False, "size": 1}},
+ "backup_id": "backup2",
+ "database_included": False,
+ "date": "1980-01-01T00:00:00.000Z",
+ "extra_metadata": {
+ "instance_id": "unknown_uuid",
+ "with_automatic_settings": True,
+ },
+ "failed_agent_ids": [],
+ "folders": [
+ "media",
+ "share",
+ ],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0",
+ "name": "Test 2",
+ "with_automatic_settings": None,
+ },
+ {
+ "addons": [
+ {
+ "name": "Test",
+ "slug": "test",
+ "version": "1.0.0",
+ },
+ ],
+ "agents": {"test.remote": {"protected": False, "size": 0}},
+ "backup_id": "backup3",
+ "database_included": True,
+ "date": "1970-01-01T00:00:00.000Z",
+ "extra_metadata": {
+ "instance_id": "our_uuid",
+ "with_automatic_settings": True,
+ },
+ "failed_agent_ids": [],
+ "folders": [
+ "media",
+ "share",
+ ],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0",
+ "name": "Test",
+ "with_automatic_settings": True,
+ },
+ ]
+ remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3])
+
+ with patch(
+ "homeassistant.components.backup.backup.async_get_backup_agents"
+ ) as core_get_backup_agents:
+ core_get_backup_agents.return_value = [local_agent]
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+
+ client = await hass_client()
+ ws_client = await hass_ws_client(hass)
+
+ path_glob.return_value = []
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": backups_info,
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/config/update", "retention": {"copies": 1, "days": None}}
+ )
+ result = await ws_client.receive_json()
+ assert result["success"]
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ delete_backup = AsyncMock()
+ upload_data = "test"
+ open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+
+ with (
+ patch.object(remote_agent, "async_delete_backup", delete_backup),
+ patch.object(remote_agent, "async_upload_backup", side_effect=exception),
+ patch("pathlib.Path.open", open_mock),
+ patch("shutil.move") as move_mock,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=TEST_BACKUP_ABC123,
+ ),
+ patch("pathlib.Path.unlink") as unlink_mock,
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=test.remote",
+ data={"file": StringIO(upload_data)},
+ )
+ await hass.async_block_till_done()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.RECEIVE_FILE,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": ReceiveBackupState.COMPLETED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": backups_info,
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": {
+ "manager_state": "receive_backup",
+ "reason": None,
+ "stage": None,
+ "state": "completed",
+ },
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ await hass.async_block_till_done()
+ assert hass_storage[DOMAIN]["data"]["backups"] == [
+ {
+ "backup_id": "abc123",
+ "failed_agent_ids": ["test.remote"],
+ }
+ ]
+
+ assert resp.status == 201
+ assert open_mock.call_count == 1
+ assert move_mock.call_count == 0
+ assert unlink_mock.call_count == 1
+ assert delete_backup.call_count == 0
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize("exception", [asyncio.CancelledError("Boom!")])
+async def test_receive_backup_non_agent_upload_error(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_ws_client: WebSocketGenerator,
+ path_glob: MagicMock,
+ hass_storage: dict[str, Any],
+ exception: Exception,
+) -> None:
+ """Test non agent upload error during backup receive."""
+ local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
+ remote_agent = BackupAgentTest("remote", backups=[])
+
+ with patch(
+ "homeassistant.components.backup.backup.async_get_backup_agents"
+ ) as core_get_backup_agents:
+ core_get_backup_agents.return_value = [local_agent]
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+
+ client = await hass_client()
+ ws_client = await hass_ws_client(hass)
+
+ path_glob.return_value = []
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": [],
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ upload_data = "test"
+ open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+
+ with (
+ patch.object(remote_agent, "async_upload_backup", side_effect=exception),
+ patch("pathlib.Path.open", open_mock),
+ patch("shutil.move") as move_mock,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=TEST_BACKUP_ABC123,
+ ),
+ patch("pathlib.Path.unlink") as unlink_mock,
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=test.remote",
+ data={"file": StringIO(upload_data)},
+ )
+ await hass.async_block_till_done()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.RECEIVE_FILE,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ assert DOMAIN not in hass_storage
+ assert resp.status == 500
+ assert open_mock.call_count == 1
+ assert move_mock.call_count == 0
+ assert unlink_mock.call_count == 0
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
- ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"),
+ (
+ "open_call_count",
+ "open_exception",
+ "write_call_count",
+ "write_exception",
+ "close_call_count",
+ "close_exception",
+ ),
[
- (LOCAL_AGENT_ID, None, True, False, "backups"),
- (LOCAL_AGENT_ID, "abc123", False, True, "backups"),
- ("test.remote", None, True, True, "tmp_backups"),
+ (1, OSError("Boom!"), 0, None, 0, None),
+ (1, None, 1, OSError("Boom!"), 1, None),
+ (1, None, 1, None, 1, OSError("Boom!")),
],
)
-async def test_async_trigger_restore(
+async def test_receive_backup_file_write_error(
hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_ws_client: WebSocketGenerator,
+ path_glob: MagicMock,
+ open_call_count: int,
+ open_exception: Exception | None,
+ write_call_count: int,
+ write_exception: Exception | None,
+ close_call_count: int,
+ close_exception: Exception | None,
+) -> None:
+ """Test file write error during backup receive."""
+ local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
+ remote_agent = BackupAgentTest("remote", backups=[])
+ with patch(
+ "homeassistant.components.backup.backup.async_get_backup_agents"
+ ) as core_get_backup_agents:
+ core_get_backup_agents.return_value = [local_agent]
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+
+ client = await hass_client()
+ ws_client = await hass_ws_client(hass)
+
+ path_glob.return_value = []
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": [],
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ upload_data = "test"
+ open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+ open_mock.side_effect = open_exception
+ open_mock.return_value.write.side_effect = write_exception
+ open_mock.return_value.close.side_effect = close_exception
+
+ with (
+ patch("pathlib.Path.open", open_mock),
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=test.remote",
+ data={"file": StringIO(upload_data)},
+ )
+ await hass.async_block_till_done()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.RECEIVE_FILE,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": "unknown_error",
+ "stage": None,
+ "state": ReceiveBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ assert resp.status == 500
+ assert open_mock.call_count == open_call_count
+ assert open_mock.return_value.write.call_count == write_call_count
+ assert open_mock.return_value.close.call_count == close_call_count
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ "exception",
+ [
+ OSError("Boom!"),
+ tarfile.TarError("Boom!"),
+ json.JSONDecodeError("Boom!", "test", 1),
+ KeyError("Boom!"),
+ ],
+)
+async def test_receive_backup_read_tar_error(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_ws_client: WebSocketGenerator,
+ path_glob: MagicMock,
+ exception: Exception,
+) -> None:
+ """Test read tar error during backup receive."""
+ local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
+ remote_agent = BackupAgentTest("remote", backups=[])
+ with patch(
+ "homeassistant.components.backup.backup.async_get_backup_agents"
+ ) as core_get_backup_agents:
+ core_get_backup_agents.return_value = [local_agent]
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+
+ client = await hass_client()
+ ws_client = await hass_ws_client(hass)
+
+ path_glob.return_value = []
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": [],
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ upload_data = "test"
+ open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+
+ with (
+ patch("pathlib.Path.open", open_mock),
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ side_effect=exception,
+ ) as read_backup,
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=test.remote",
+ data={"file": StringIO(upload_data)},
+ )
+ await hass.async_block_till_done()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.RECEIVE_FILE,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": "unknown_error",
+ "stage": None,
+ "state": ReceiveBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ assert resp.status == 500
+ assert read_backup.call_count == 1
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ (
+ "open_call_count",
+ "open_exception",
+ "read_call_count",
+ "read_exception",
+ "close_call_count",
+ "close_exception",
+ "unlink_call_count",
+ "unlink_exception",
+ "final_state",
+ "final_state_reason",
+ "response_status",
+ ),
+ [
+ (
+ 2,
+ [DEFAULT, OSError("Boom!")],
+ 0,
+ None,
+ 1,
+ [DEFAULT, DEFAULT],
+ 1,
+ None,
+ ReceiveBackupState.COMPLETED,
+ None,
+ 201,
+ ),
+ (
+ 2,
+ [DEFAULT, DEFAULT],
+ 1,
+ OSError("Boom!"),
+ 2,
+ [DEFAULT, DEFAULT],
+ 1,
+ None,
+ ReceiveBackupState.COMPLETED,
+ None,
+ 201,
+ ),
+ (
+ 2,
+ [DEFAULT, DEFAULT],
+ 1,
+ None,
+ 2,
+ [DEFAULT, OSError("Boom!")],
+ 1,
+ None,
+ ReceiveBackupState.COMPLETED,
+ None,
+ 201,
+ ),
+ (
+ 2,
+ [DEFAULT, DEFAULT],
+ 1,
+ None,
+ 2,
+ [DEFAULT, DEFAULT],
+ 1,
+ OSError("Boom!"),
+ ReceiveBackupState.FAILED,
+ "unknown_error",
+ 500,
+ ),
+ ],
+)
+async def test_receive_backup_file_read_error(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_ws_client: WebSocketGenerator,
+ path_glob: MagicMock,
+ open_call_count: int,
+ open_exception: list[Exception | None],
+ read_call_count: int,
+ read_exception: Exception | None,
+ close_call_count: int,
+ close_exception: list[Exception | None],
+ unlink_call_count: int,
+ unlink_exception: Exception | None,
+ final_state: ReceiveBackupState,
+ final_state_reason: str | None,
+ response_status: int,
+) -> None:
+ """Test file read error during backup receive."""
+ local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
+ remote_agent = BackupAgentTest("remote", backups=[])
+ with patch(
+ "homeassistant.components.backup.backup.async_get_backup_agents"
+ ) as core_get_backup_agents:
+ core_get_backup_agents.return_value = [local_agent]
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+
+ client = await hass_client()
+ ws_client = await hass_ws_client(hass)
+
+ path_glob.return_value = []
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": [],
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ upload_data = "test"
+ open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+
+ open_mock.side_effect = open_exception
+ open_mock.return_value.read.side_effect = read_exception
+ open_mock.return_value.close.side_effect = close_exception
+
+ with (
+ patch("pathlib.Path.open", open_mock),
+ patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=TEST_BACKUP_ABC123,
+ ),
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=test.remote",
+ data={"file": StringIO(upload_data)},
+ )
+ await hass.async_block_till_done()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.RECEIVE_FILE,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": None,
+ "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS,
+ "state": ReceiveBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RECEIVE_BACKUP,
+ "reason": final_state_reason,
+ "stage": None,
+ "state": final_state,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ assert resp.status == response_status
+ assert open_mock.call_count == open_call_count
+ assert open_mock.return_value.read.call_count == read_call_count
+ assert open_mock.return_value.close.call_count == close_call_count
+ assert unlink_mock.call_count == unlink_call_count
+
+
+@pytest.mark.usefixtures("path_glob")
+@pytest.mark.parametrize(
+ (
+ "agent_id",
+ "backup_id",
+ "password_param",
+ "backup_path",
+ "restore_database",
+ "restore_homeassistant",
+ "dir",
+ ),
+ [
+ (
+ LOCAL_AGENT_ID,
+ TEST_BACKUP_ABC123.backup_id,
+ {},
+ TEST_BACKUP_PATH_ABC123,
+ True,
+ False,
+ "backups",
+ ),
+ (
+ LOCAL_AGENT_ID,
+ TEST_BACKUP_DEF456.backup_id,
+ {},
+ TEST_BACKUP_PATH_DEF456,
+ True,
+ False,
+ "backups",
+ ),
+ (
+ LOCAL_AGENT_ID,
+ TEST_BACKUP_ABC123.backup_id,
+ {"password": "abc123"},
+ TEST_BACKUP_PATH_ABC123,
+ False,
+ True,
+ "backups",
+ ),
+ (
+ "test.remote",
+ TEST_BACKUP_ABC123.backup_id,
+ {},
+ TEST_BACKUP_PATH_ABC123,
+ True,
+ True,
+ "tmp_backups",
+ ),
+ ],
+)
+async def test_restore_backup(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
agent_id: str,
- password: str | None,
+ backup_id: str,
+ password_param: dict[str, str],
+ backup_path: Path,
restore_database: bool,
restore_homeassistant: bool,
dir: str,
) -> None:
- """Test trigger restore."""
- manager = BackupManager(hass, CoreBackupReaderWriter(hass))
- hass.data[DATA_MANAGER] = manager
-
- await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
+ """Test restore backup."""
+ password = password_param.get("password")
+ remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
- async_get_backup_agents=AsyncMock(
- return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])]
- ),
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
- await manager.load_platforms()
- local_agent = manager.backup_agents[LOCAL_AGENT_ID]
- local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}
- local_agent._loaded_backups = True
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
with (
patch("pathlib.Path.exists", return_value=True),
@@ -1533,135 +2684,879 @@ async def test_async_trigger_restore(
patch(
"homeassistant.components.backup.manager.validate_password"
) as validate_password_mock,
- patch.object(BackupAgentTest, "async_download_backup") as download_mock,
+ patch.object(remote_agent, "async_download_backup") as download_mock,
+ patch(
+ "homeassistant.components.backup.backup.read_backup",
+ side_effect=mock_read_backup,
+ ),
):
download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
- await manager.async_restore_backup(
- TEST_BACKUP_ABC123.backup_id,
- agent_id=agent_id,
- password=password,
- restore_addons=None,
- restore_database=restore_database,
- restore_folders=None,
- restore_homeassistant=restore_homeassistant,
- )
- backup_path = f"{hass.config.path()}/{dir}/abc123.tar"
- expected_restore_file = json.dumps(
+ await ws_client.send_json_auto_id(
{
- "path": backup_path,
- "password": password,
- "remove_after_restore": agent_id != LOCAL_AGENT_ID,
+ "type": "backup/restore",
+ "backup_id": backup_id,
+ "agent_id": agent_id,
"restore_database": restore_database,
"restore_homeassistant": restore_homeassistant,
}
+ | password_param
)
- validate_password_mock.assert_called_once_with(Path(backup_path), password)
- assert mocked_write_text.call_args[0][0] == expected_restore_file
- assert mocked_service_call.called
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.CORE_RESTART,
+ }
+
+ # Note: The core restart is not tested here, in reality the following events
+ # are not sent because the core restart closes the WS connection.
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.COMPLETED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}"
+ expected_restore_file = json.dumps(
+ {
+ "path": full_backup_path,
+ "password": password,
+ "remove_after_restore": agent_id != LOCAL_AGENT_ID,
+ "restore_database": restore_database,
+ "restore_homeassistant": restore_homeassistant,
+ }
+ )
+ validate_password_mock.assert_called_once_with(Path(full_backup_path), password)
+ assert mocked_write_text.call_args[0][0] == expected_restore_file
+ assert mocked_service_call.called
-async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None:
- """Test trigger restore."""
+@pytest.mark.usefixtures("path_glob")
+@pytest.mark.parametrize(
+ ("agent_id", "dir"), [(LOCAL_AGENT_ID, "backups"), ("test.remote", "tmp_backups")]
+)
+async def test_restore_backup_wrong_password(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ agent_id: str,
+ dir: str,
+) -> None:
+ """Test restore backup wrong password."""
password = "hunter2"
- manager = BackupManager(hass, CoreBackupReaderWriter(hass))
- hass.data[DATA_MANAGER] = manager
-
- await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
+ remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
- async_get_backup_agents=AsyncMock(
- return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])]
- ),
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
- await manager.load_platforms()
- local_agent = manager.backup_agents[LOCAL_AGENT_ID]
- local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}
- local_agent._loaded_backups = True
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ with (
+ patch("pathlib.Path.exists", return_value=True),
+ patch("pathlib.Path.open"),
+ patch("pathlib.Path.write_text") as mocked_write_text,
+ patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
+ patch(
+ "homeassistant.components.backup.manager.validate_password"
+ ) as validate_password_mock,
+ patch.object(remote_agent, "async_download_backup") as download_mock,
+ patch(
+ "homeassistant.components.backup.backup.read_backup",
+ side_effect=mock_read_backup,
+ ),
+ ):
+ download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
+ validate_password_mock.return_value = False
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": agent_id,
+ "password": password,
+ }
+ )
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": "password_incorrect",
+ "stage": None,
+ "state": RestoreBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert not result["success"]
+ assert result["error"]["code"] == "password_incorrect"
+
+ backup_path = f"{hass.config.path()}/{dir}/abc123.tar"
+ validate_password_mock.assert_called_once_with(Path(backup_path), password)
+ mocked_write_text.assert_not_called()
+ mocked_service_call.assert_not_called()
+
+
+@pytest.mark.usefixtures("path_glob")
+@pytest.mark.parametrize(
+ ("parameters", "expected_error", "expected_reason"),
+ [
+ (
+ {"backup_id": "no_such_backup"},
+ f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}",
+ "backup_manager_error",
+ ),
+ (
+ {"restore_addons": ["blah"]},
+ "Addons and folders are not supported in core restore",
+ "backup_reader_writer_error",
+ ),
+ (
+ {"restore_folders": [Folder.ADDONS]},
+ "Addons and folders are not supported in core restore",
+ "backup_reader_writer_error",
+ ),
+ (
+ {"restore_database": False, "restore_homeassistant": False},
+ "Home Assistant or database must be included in restore",
+ "backup_reader_writer_error",
+ ),
+ ],
+)
+async def test_restore_backup_wrong_parameters(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ parameters: dict[str, Any],
+ expected_error: str,
+ expected_reason: str,
+) -> None:
+ """Test restore backup wrong parameters."""
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
with (
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.write_text") as mocked_write_text,
patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
patch(
- "homeassistant.components.backup.manager.validate_password"
- ) as validate_password_mock,
+ "homeassistant.components.backup.backup.read_backup",
+ side_effect=mock_read_backup,
+ ),
):
- validate_password_mock.return_value = False
- with pytest.raises(
- HomeAssistantError, match="The password provided is incorrect."
- ):
- await manager.async_restore_backup(
- TEST_BACKUP_ABC123.backup_id,
- agent_id=LOCAL_AGENT_ID,
- password=password,
- restore_addons=None,
- restore_database=True,
- restore_folders=None,
- restore_homeassistant=True,
- )
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": LOCAL_AGENT_ID,
+ }
+ | parameters
+ )
- backup_path = f"{hass.config.path()}/backups/abc123.tar"
- validate_password_mock.assert_called_once_with(Path(backup_path), password)
- mocked_write_text.assert_not_called()
- mocked_service_call.assert_not_called()
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.IN_PROGRESS,
+ }
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": expected_reason,
+ "stage": None,
+ "state": RestoreBackupState.FAILED,
+ }
-@pytest.mark.parametrize(
- ("parameters", "expected_error"),
- [
- (
- {"backup_id": TEST_BACKUP_DEF456.backup_id},
- "Backup def456 not found",
- ),
- (
- {"restore_addons": ["blah"]},
- "Addons and folders are not supported in core restore",
- ),
- (
- {"restore_folders": [Folder.ADDONS]},
- "Addons and folders are not supported in core restore",
- ),
- (
- {"restore_database": False, "restore_homeassistant": False},
- "Home Assistant or database must be included in restore",
- ),
- ],
-)
-async def test_async_trigger_restore_wrong_parameters(
- hass: HomeAssistant, parameters: dict[str, Any], expected_error: str
-) -> None:
- """Test trigger restore."""
- manager = BackupManager(hass, CoreBackupReaderWriter(hass))
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
- await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
- await manager.load_platforms()
-
- local_agent = manager.backup_agents[LOCAL_AGENT_ID]
- local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}
- local_agent._loaded_backups = True
-
- default_parameters = {
- "agent_id": LOCAL_AGENT_ID,
- "backup_id": TEST_BACKUP_ABC123.backup_id,
- "password": None,
- "restore_addons": None,
- "restore_database": True,
- "restore_folders": None,
- "restore_homeassistant": True,
- }
-
- with (
- patch("pathlib.Path.exists", return_value=True),
- patch("pathlib.Path.write_text") as mocked_write_text,
- patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
- pytest.raises(HomeAssistantError, match=expected_error),
- ):
- await manager.async_restore_backup(**(default_parameters | parameters))
+ result = await ws_client.receive_json()
+ assert not result["success"]
+ assert result["error"]["code"] == "home_assistant_error"
+ assert result["error"]["message"] == expected_error
mocked_write_text.assert_not_called()
mocked_service_call.assert_not_called()
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+async def test_restore_backup_when_busy(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test restore backup with busy manager."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]}
+ )
+ result = await ws_client.receive_json()
+
+ assert result["success"] is True
+
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": LOCAL_AGENT_ID,
+ }
+ )
+ result = await ws_client.receive_json()
+
+ assert result["success"] is False
+ assert result["error"]["code"] == "home_assistant_error"
+ assert result["error"]["message"] == "Backup manager busy: create_backup"
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ ("exception", "error_code", "error_message", "expected_reason"),
+ [
+ (
+ BackupAgentError("Boom!"),
+ "home_assistant_error",
+ "Boom!",
+ "backup_agent_error",
+ ),
+ (
+ Exception("Boom!"),
+ "unknown_error",
+ "Unknown error",
+ "unknown_error",
+ ),
+ ],
+)
+async def test_restore_backup_agent_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ exception: Exception,
+ error_code: str,
+ error_message: str,
+ expected_reason: str,
+) -> None:
+ """Test restore backup with agent error."""
+ remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ with (
+ patch("pathlib.Path.open"),
+ patch("pathlib.Path.write_text") as mocked_write_text,
+ patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
+ patch.object(
+ remote_agent, "async_download_backup", side_effect=exception
+ ) as download_mock,
+ ):
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": remote_agent.agent_id,
+ }
+ )
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": expected_reason,
+ "stage": None,
+ "state": RestoreBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert not result["success"]
+ assert result["error"]["code"] == error_code
+ assert result["error"]["message"] == error_message
+
+ assert download_mock.call_count == 1
+ assert mocked_write_text.call_count == 0
+ assert mocked_service_call.call_count == 0
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ (
+ "open_call_count",
+ "open_exception",
+ "write_call_count",
+ "write_exception",
+ "close_call_count",
+ "close_exception",
+ "write_text_call_count",
+ "write_text_exception",
+ "validate_password_call_count",
+ ),
+ [
+ (
+ 1,
+ OSError("Boom!"),
+ 0,
+ None,
+ 0,
+ None,
+ 0,
+ None,
+ 0,
+ ),
+ (
+ 1,
+ None,
+ 1,
+ OSError("Boom!"),
+ 1,
+ None,
+ 0,
+ None,
+ 0,
+ ),
+ (
+ 1,
+ None,
+ 1,
+ None,
+ 1,
+ OSError("Boom!"),
+ 0,
+ None,
+ 0,
+ ),
+ (
+ 1,
+ None,
+ 1,
+ None,
+ 1,
+ None,
+ 1,
+ OSError("Boom!"),
+ 1,
+ ),
+ ],
+)
+async def test_restore_backup_file_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ open_call_count: int,
+ open_exception: list[Exception | None],
+ write_call_count: int,
+ write_exception: Exception | None,
+ close_call_count: int,
+ close_exception: list[Exception | None],
+ write_text_call_count: int,
+ write_text_exception: Exception | None,
+ validate_password_call_count: int,
+) -> None:
+ """Test restore backup with file error."""
+ remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ open_mock = mock_open()
+ open_mock.side_effect = open_exception
+ open_mock.return_value.write.side_effect = write_exception
+ open_mock.return_value.close.side_effect = close_exception
+
+ with (
+ patch("pathlib.Path.open", open_mock),
+ patch(
+ "pathlib.Path.write_text", side_effect=write_text_exception
+ ) as mocked_write_text,
+ patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
+ patch(
+ "homeassistant.components.backup.manager.validate_password"
+ ) as validate_password_mock,
+ patch.object(remote_agent, "async_download_backup") as download_mock,
+ ):
+ download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": remote_agent.agent_id,
+ }
+ )
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": RestoreBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.RESTORE_BACKUP,
+ "reason": "unknown_error",
+ "stage": None,
+ "state": RestoreBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert not result["success"]
+ assert result["error"]["code"] == "unknown_error"
+ assert result["error"]["message"] == "Unknown error"
+
+ assert download_mock.call_count == 1
+ assert validate_password_mock.call_count == validate_password_call_count
+ assert open_mock.call_count == open_call_count
+ assert open_mock.return_value.write.call_count == write_call_count
+ assert open_mock.return_value.close.call_count == close_call_count
+ assert mocked_write_text.call_count == write_text_call_count
+ assert mocked_service_call.call_count == 0
+
+
+@pytest.mark.parametrize(
+ ("commands", "agent_ids", "password", "protected_backup", "inner_tar_key"),
+ [
+ (
+ [],
+ ["backup.local", "test.remote"],
+ None,
+ {"backup.local": False, "test.remote": False},
+ None,
+ ),
+ (
+ [],
+ ["backup.local", "test.remote"],
+ "hunter2",
+ {"backup.local": True, "test.remote": True},
+ password_to_key("hunter2"),
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": False},
+ "test.remote": {"protected": False},
+ },
+ }
+ ],
+ ["backup.local", "test.remote"],
+ "hunter2",
+ {"backup.local": False, "test.remote": False},
+ None, # None of the agents are protected
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": False},
+ "test.remote": {"protected": True},
+ },
+ }
+ ],
+ ["backup.local", "test.remote"],
+ "hunter2",
+ {"backup.local": False, "test.remote": True},
+ None, # Local agent is not protected
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": True},
+ "test.remote": {"protected": False},
+ },
+ }
+ ],
+ ["backup.local", "test.remote"],
+ "hunter2",
+ {"backup.local": True, "test.remote": False},
+ password_to_key("hunter2"), # Local agent is protected
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": True},
+ "test.remote": {"protected": True},
+ },
+ }
+ ],
+ ["backup.local", "test.remote"],
+ "hunter2",
+ {"backup.local": True, "test.remote": True},
+ password_to_key("hunter2"),
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": False},
+ "test.remote": {"protected": True},
+ },
+ }
+ ],
+ ["backup.local", "test.remote"],
+ None,
+ {"backup.local": False, "test.remote": False},
+ None, # No password supplied
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": False},
+ "test.remote": {"protected": True},
+ },
+ }
+ ],
+ ["test.remote"],
+ "hunter2",
+ {"test.remote": True},
+ password_to_key("hunter2"),
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "backup.local": {"protected": False},
+ "test.remote": {"protected": False},
+ },
+ }
+ ],
+ ["test.remote"],
+ "hunter2",
+ {"test.remote": False},
+ password_to_key("hunter2"), # Temporary backup protected when password set
+ ),
+ ],
+)
+@pytest.mark.usefixtures("mock_backup_generation")
+async def test_initiate_backup_per_agent_encryption(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ generate_backup_id: MagicMock,
+ mocked_tarfile: Mock,
+ path_glob: MagicMock,
+ commands: dict[str, Any],
+ agent_ids: list[str],
+ password: str | None,
+ protected_backup: dict[str, bool],
+ inner_tar_key: bytes | None,
+) -> None:
+ """Test generate backup where encryption is selectively set on agents."""
+ local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
+ remote_agent = BackupAgentTest("remote", backups=[])
+
+ with patch(
+ "homeassistant.components.backup.backup.async_get_backup_agents"
+ ) as core_get_backup_agents:
+ core_get_backup_agents.return_value = [local_agent]
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+
+ ws_client = await hass_ws_client(hass)
+
+ path_glob.return_value = []
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+ assert result["result"] == {
+ "backups": [],
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ for command in commands:
+ await ws_client.send_json_auto_id(command)
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ with (
+ patch("pathlib.Path.open", mock_open(read_data=b"test")),
+ ):
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/generate",
+ "agent_ids": agent_ids,
+ "password": password,
+ "name": "test",
+ }
+ )
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ backup_id = result["result"]["backup_job_id"]
+ assert backup_id == generate_backup_id.return_value
+
+ await hass.async_block_till_done()
+
+ mocked_tarfile.return_value.create_inner_tar.assert_called_once_with(
+ ANY, gzip=True, key=inner_tar_key
+ )
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
+ "stage": CreateBackupStage.HOME_ASSISTANT,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
+ "stage": CreateBackupStage.UPLOAD_TO_AGENTS,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "reason": None,
+ "stage": None,
+ "state": CreateBackupState.COMPLETED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/details", "backup_id": backup_id}
+ )
+ result = await ws_client.receive_json()
+
+ backup_data = result["result"]["backup"]
+
+ assert backup_data == {
+ "addons": [],
+ "agents": {
+ agent_id: {"protected": protected_backup[agent_id], "size": ANY}
+ for agent_id in agent_ids
+ },
+ "backup_id": backup_id,
+ "database_included": True,
+ "date": ANY,
+ "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False},
+ "failed_agent_ids": [],
+ "folders": [],
+ "homeassistant_included": True,
+ "homeassistant_version": "2025.1.0",
+ "name": "test",
+ "with_automatic_settings": False,
+ }
+
+
+@pytest.mark.parametrize(
+ ("restore_result", "last_non_idle_event"),
+ [
+ (
+ {"error": None, "error_type": None, "success": True},
+ {
+ "manager_state": "restore_backup",
+ "reason": None,
+ "stage": None,
+ "state": "completed",
+ },
+ ),
+ (
+ {"error": "Boom!", "error_type": "ValueError", "success": False},
+ {
+ "manager_state": "restore_backup",
+ "reason": "Boom!",
+ "stage": None,
+ "state": "failed",
+ },
+ ),
+ ],
+)
+async def test_restore_progress_after_restart(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ restore_result: dict[str, Any],
+ last_non_idle_event: dict[str, Any],
+) -> None:
+ """Test restore backup progress after restart."""
+
+ with patch(
+ "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode()
+ ):
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ ws_client = await hass_ws_client(hass)
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+ assert result["result"] == {
+ "agent_errors": {},
+ "backups": [],
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": last_non_idle_event,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+
+async def test_restore_progress_after_restart_fail_to_remove(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test restore backup progress after restart when failing to remove result file."""
+
+ with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")):
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ ws_client = await hass_ws_client(hass)
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+ assert result["result"] == {
+ "agent_errors": {},
+ "backups": [],
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
+ }
+
+ assert (
+ "Unexpected error deleting backup restore result file: Boom!"
+ in caplog.text
+ )
diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py
new file mode 100644
index 00000000000..f05afbea9ec
--- /dev/null
+++ b/tests/components/backup/test_store.py
@@ -0,0 +1,124 @@
+"""Tests for the Backup integration."""
+
+from collections.abc import Generator
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.backup.const import DOMAIN
+from homeassistant.core import HomeAssistant
+
+from .common import setup_backup_integration
+
+from tests.typing import WebSocketGenerator
+
+
+@pytest.fixture(autouse=True)
+def mock_delay_save() -> Generator[None]:
+ """Mock the delay save constant."""
+ with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0):
+ yield
+
+
+@pytest.mark.parametrize(
+ "store_data",
+ [
+ {
+ "data": {
+ "backups": [
+ {
+ "backup_id": "abc123",
+ "failed_agent_ids": ["test.remote"],
+ }
+ ],
+ "config": {
+ "create_backup": {
+ "agent_ids": [],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "retention": {
+ "copies": None,
+ "days": None,
+ },
+ "schedule": {
+ "state": "never",
+ },
+ },
+ },
+ "key": DOMAIN,
+ "version": 1,
+ },
+ {
+ "data": {
+ "backups": [
+ {
+ "backup_id": "abc123",
+ "failed_agent_ids": ["test.remote"],
+ }
+ ],
+ "config": {
+ "agents": {"test.remote": {"protected": True}},
+ "create_backup": {
+ "agent_ids": [],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "retention": {
+ "copies": None,
+ "days": None,
+ },
+ "schedule": {
+ "days": [],
+ "recurrence": "never",
+ "time": None,
+ },
+ "something_from_the_future": "value",
+ },
+ },
+ "key": DOMAIN,
+ "version": 2,
+ },
+ ],
+)
+async def test_store_migration(
+ hass: HomeAssistant,
+ hass_storage: dict[str, Any],
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ store_data: dict[str, Any],
+) -> None:
+ """Test migrating the backup store."""
+ hass_storage[DOMAIN] = store_data
+ await setup_backup_integration(hass)
+ await hass.async_block_till_done()
+
+ # Check migrated data
+ assert hass_storage[DOMAIN] == snapshot
+
+ # Update settings, then check saved data
+ client = await hass_ws_client(hass)
+ await client.send_json_auto_id(
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ }
+ )
+ result = await client.receive_json()
+ assert result["success"]
+ await hass.async_block_till_done()
+ assert hass_storage[DOMAIN] == snapshot
diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py
index 60cfc77b1aa..504e0d56d58 100644
--- a/tests/components/backup/test_util.py
+++ b/tests/components/backup/test_util.py
@@ -2,13 +2,26 @@
from __future__ import annotations
+import asyncio
+from collections.abc import AsyncIterator
+import dataclasses
import tarfile
from unittest.mock import Mock, patch
import pytest
+import securetar
-from homeassistant.components.backup import AddonInfo, AgentBackup, Folder
-from homeassistant.components.backup.util import read_backup, validate_password
+from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder
+from homeassistant.components.backup.util import (
+ DecryptedBackupStreamer,
+ EncryptedBackupStreamer,
+ read_backup,
+ suggested_filename,
+ validate_password,
+)
+from homeassistant.core import HomeAssistant
+
+from tests.common import get_fixture_path
@pytest.mark.parametrize(
@@ -130,3 +143,411 @@ def test_validate_password_no_homeassistant() -> None:
KeyError
)
assert validate_password(mock_path, "hunter2") is False
+
+
+async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None:
+ """Test the decrypted backup streamer."""
+ decrypted_backup_path = get_fixture_path(
+ "test_backups/c0cb53bd.tar.decrypted", DOMAIN
+ )
+ encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=True,
+ size=encrypted_backup_path.stat().st_size,
+ )
+ expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = encrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ assert decryptor.backup() == dataclasses.replace(
+ backup, protected=False, size=backup.size + len(expected_padding)
+ )
+ decrypted_stream = await decryptor.open_stream()
+ decrypted_output = b""
+ async for chunk in decrypted_stream:
+ decrypted_output += chunk
+ await decryptor.wait()
+
+ # Expect the output to match the stored decrypted backup file, with additional
+ # padding.
+ decrypted_backup_data = decrypted_backup_path.read_bytes()
+ assert decrypted_output == decrypted_backup_data + expected_padding
+
+
+async def test_decrypted_backup_streamer_interrupt_stuck_reader(
+ hass: HomeAssistant,
+) -> None:
+ """Test the decrypted backup streamer."""
+ encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=True,
+ size=encrypted_backup_path.stat().st_size,
+ )
+
+ stuck = asyncio.Event()
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = encrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ await stuck.wait()
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ await decryptor.open_stream()
+ await decryptor.wait()
+
+
+async def test_decrypted_backup_streamer_interrupt_stuck_writer(
+ hass: HomeAssistant,
+) -> None:
+ """Test the decrypted backup streamer."""
+ encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=True,
+ size=encrypted_backup_path.stat().st_size,
+ )
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = encrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ await decryptor.open_stream()
+ await decryptor.wait()
+
+
+async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None:
+ """Test the decrypted backup streamer with wrong password."""
+ encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=True,
+ size=encrypted_backup_path.stat().st_size,
+ )
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = encrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "wrong_password")
+ decrypted_stream = await decryptor.open_stream()
+ async for _ in decrypted_stream:
+ pass
+
+ await decryptor.wait()
+ assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError)
+
+
+async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None:
+ """Test the encrypted backup streamer."""
+ decrypted_backup_path = get_fixture_path(
+ "test_backups/c0cb53bd.tar.decrypted", DOMAIN
+ )
+ encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=False,
+ size=decrypted_backup_path.stat().st_size,
+ )
+ expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = decrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ # Patch os.urandom to return values matching the nonce used in the encrypted
+ # test backup. The backup has three inner tar files, but we need an extra nonce
+ # for a future planned supervisor.tar.
+ with patch("os.urandom") as mock_randbytes:
+ mock_randbytes.side_effect = (
+ bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"),
+ bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"),
+ bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"),
+ bytes.fromhex("00000000000000000000000000000000"),
+ )
+ encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ assert encryptor.backup() == dataclasses.replace(
+ backup, protected=True, size=backup.size + len(expected_padding)
+ )
+
+ encrypted_stream = await encryptor.open_stream()
+ encrypted_output = b""
+ async for chunk in encrypted_stream:
+ encrypted_output += chunk
+ await encryptor.wait()
+
+ # Expect the output to match the stored encrypted backup file, with additional
+ # padding.
+ encrypted_backup_data = encrypted_backup_path.read_bytes()
+ assert encrypted_output == encrypted_backup_data + expected_padding
+
+
+async def test_encrypted_backup_streamer_interrupt_stuck_reader(
+ hass: HomeAssistant,
+) -> None:
+ """Test the encrypted backup streamer."""
+ decrypted_backup_path = get_fixture_path(
+ "test_backups/c0cb53bd.tar.decrypted", DOMAIN
+ )
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=False,
+ size=decrypted_backup_path.stat().st_size,
+ )
+
+ stuck = asyncio.Event()
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = decrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ await stuck.wait()
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ await decryptor.open_stream()
+ await decryptor.wait()
+
+
+async def test_encrypted_backup_streamer_interrupt_stuck_writer(
+ hass: HomeAssistant,
+) -> None:
+ """Test the encrypted backup streamer."""
+ decrypted_backup_path = get_fixture_path(
+ "test_backups/c0cb53bd.tar.decrypted", DOMAIN
+ )
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=True,
+ size=decrypted_backup_path.stat().st_size,
+ )
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = decrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ await decryptor.open_stream()
+ await decryptor.wait()
+
+
+async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None:
+ """Test the encrypted backup streamer."""
+ decrypted_backup_path = get_fixture_path(
+ "test_backups/c0cb53bd.tar.decrypted", DOMAIN
+ )
+ encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=False,
+ size=decrypted_backup_path.stat().st_size,
+ )
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = decrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ encryptor1 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+ encryptor2 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+
+ async def read_stream(stream: AsyncIterator[bytes]) -> bytes:
+ output = b""
+ async for chunk in stream:
+ output += chunk
+ return output
+
+ # When reading twice from the same streamer, the same nonce is used.
+ encrypted_output1 = await read_stream(await encryptor1.open_stream())
+ encrypted_output2 = await read_stream(await encryptor1.open_stream())
+ assert encrypted_output1 == encrypted_output2
+
+ encrypted_output3 = await read_stream(await encryptor2.open_stream())
+ encrypted_output4 = await read_stream(await encryptor2.open_stream())
+ assert encrypted_output3 == encrypted_output4
+
+ # Wait for workers to terminate
+ await encryptor1.wait()
+ await encryptor2.wait()
+
+ # Output from the two streames should differ but have the same length.
+ assert encrypted_output1 != encrypted_output3
+ assert len(encrypted_output1) == len(encrypted_output3)
+
+ # Expect the output length to match the stored encrypted backup file, with
+ # additional padding.
+ encrypted_backup_data = encrypted_backup_path.read_bytes()
+ # 4 x 10240 byte of padding
+ assert len(encrypted_output1) == len(encrypted_backup_data) + 40960
+ assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data
+
+
+async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None:
+ """Test the encrypted backup streamer."""
+ decrypted_backup_path = get_fixture_path(
+ "test_backups/c0cb53bd.tar.decrypted", DOMAIN
+ )
+ backup = AgentBackup(
+ addons=["addon_1", "addon_2"],
+ backup_id="1234",
+ date="2024-12-02T07:23:58.261875-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name="test",
+ protected=False,
+ size=decrypted_backup_path.stat().st_size,
+ )
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = decrypted_backup_path.open("rb")
+ while chunk := f.read(1024):
+ yield chunk
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ # Patch os.urandom to return values matching the nonce used in the encrypted
+ # test backup. The backup has three inner tar files, but we need an extra nonce
+ # for a future planned supervisor.tar.
+ encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
+
+ with patch(
+ "homeassistant.components.backup.util.tarfile.open",
+ side_effect=tarfile.TarError,
+ ):
+ encrypted_stream = await encryptor.open_stream()
+ async for _ in encrypted_stream:
+ pass
+
+ # Expect the output to match the stored encrypted backup file, with additional
+ # padding.
+ await encryptor.wait()
+ assert isinstance(encryptor._workers[0].error, tarfile.TarError)
+
+
+@pytest.mark.parametrize(
+ ("name", "resulting_filename"),
+ [
+ ("test", "test_2025-01-30_13.42_12345678.tar"),
+ (" leading spaces", "leading_spaces_2025-01-30_13.42_12345678.tar"),
+ ("trailing spaces ", "trailing_spaces_2025-01-30_13.42_12345678.tar"),
+ ("double spaces ", "double_spaces_2025-01-30_13.42_12345678.tar"),
+ ],
+)
+def test_suggested_filename(name: str, resulting_filename: str) -> None:
+ """Test suggesting a filename."""
+ backup = AgentBackup(
+ addons=[],
+ backup_id="1234",
+ date="2025-01-30 13:42:12.345678-05:00",
+ database_included=False,
+ extra_metadata={},
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0.dev0",
+ name=name,
+ protected=False,
+ size=1234,
+ )
+ assert suggested_filename(backup) == resulting_filename
diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py
index e95481373d6..5af6d595938 100644
--- a/tests/components/backup/test_websocket.py
+++ b/tests/components/backup/test_websocket.py
@@ -12,8 +12,10 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgentError,
BackupAgentPlatformProtocol,
+ BackupNotFound,
BackupReaderWriterError,
Folder,
+ store,
)
from homeassistant.components.backup.agent import BackupAgentUnreachableError
from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
@@ -53,8 +55,9 @@ BACKUP_CALL = call(
)
DEFAULT_STORAGE_DATA: dict[str, Any] = {
- "backups": {},
+ "backups": [],
"config": {
+ "agents": {},
"create_backup": {
"agent_ids": [],
"include_addons": None,
@@ -70,11 +73,10 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = {
"copies": None,
"days": None,
},
- "schedule": {
- "state": "never",
- },
+ "schedule": {"days": [], "recurrence": "never", "state": "never", "time": None},
},
}
+DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
@pytest.fixture
@@ -305,7 +307,8 @@ async def test_delete_with_errors(
hass_storage[DOMAIN] = {
"data": storage_data,
"key": DOMAIN,
- "version": 1,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
}
await setup_backup_integration(
hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
@@ -586,6 +589,8 @@ async def test_generate_with_default_settings_calls_create(
last_completed_automatic_backup: str,
) -> None:
"""Test backup/generate_with_automatic_settings calls async_initiate_backup."""
+ created_backup: MagicMock = create_backup.return_value[1].result().backup
+ created_backup.protected = create_backup_settings["password"] is not None
client = await hass_ws_client(hass)
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-13T12:01:00+01:00")
@@ -906,218 +911,412 @@ async def test_agents_info(
@pytest.mark.parametrize(
"storage_data",
[
- None,
+ {},
{
- "backups": {},
- "config": {
- "create_backup": {
- "agent_ids": ["test-agent"],
- "include_addons": ["test-addon"],
- "include_all_addons": True,
- "include_database": True,
- "include_folders": ["media"],
- "name": "test-name",
- "password": "test-password",
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {},
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["test-addon"],
+ "include_all_addons": True,
+ "include_database": True,
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ "retention": {"copies": 3, "days": 7},
+ "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00",
+ "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00",
+ "schedule": {
+ "days": DAILY,
+ "recurrence": "custom_days",
+ "state": "never",
+ "time": None,
+ },
+ },
},
- "retention": {"copies": 3, "days": 7},
- "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00",
- "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00",
- "schedule": {"state": "daily"},
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
},
},
{
- "backups": {},
- "config": {
- "create_backup": {
- "agent_ids": ["test-agent"],
- "include_addons": None,
- "include_all_addons": False,
- "include_database": False,
- "include_folders": None,
- "name": None,
- "password": None,
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {},
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": False,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "retention": {"copies": 3, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {
+ "days": [],
+ "recurrence": "never",
+ "state": "never",
+ "time": None,
+ },
+ },
},
- "retention": {"copies": 3, "days": None},
- "last_attempted_automatic_backup": None,
- "last_completed_automatic_backup": None,
- "schedule": {"state": "never"},
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
},
},
{
- "backups": {},
- "config": {
- "create_backup": {
- "agent_ids": ["test-agent"],
- "include_addons": None,
- "include_all_addons": False,
- "include_database": False,
- "include_folders": None,
- "name": None,
- "password": None,
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {},
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": False,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "retention": {"copies": None, "days": 7},
+ "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00",
+ "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00",
+ "schedule": {
+ "days": [],
+ "recurrence": "never",
+ "state": "never",
+ "time": None,
+ },
+ },
},
- "retention": {"copies": None, "days": 7},
- "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00",
- "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00",
- "schedule": {"state": "never"},
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
},
},
{
- "backups": {},
- "config": {
- "create_backup": {
- "agent_ids": ["test-agent"],
- "include_addons": None,
- "include_all_addons": False,
- "include_database": False,
- "include_folders": None,
- "name": None,
- "password": None,
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {},
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": False,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {
+ "days": ["mon"],
+ "recurrence": "custom_days",
+ "state": "never",
+ "time": None,
+ },
+ },
},
- "retention": {"copies": None, "days": None},
- "last_attempted_automatic_backup": None,
- "last_completed_automatic_backup": None,
- "schedule": {"state": "mon"},
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
},
},
{
- "backups": {},
- "config": {
- "create_backup": {
- "agent_ids": ["test-agent"],
- "include_addons": None,
- "include_all_addons": False,
- "include_database": False,
- "include_folders": None,
- "name": None,
- "password": None,
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {},
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": False,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {
+ "days": [],
+ "recurrence": "never",
+ "state": "never",
+ "time": None,
+ },
+ },
},
- "retention": {"copies": None, "days": None},
- "last_attempted_automatic_backup": None,
- "last_completed_automatic_backup": None,
- "schedule": {"state": "sat"},
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
+ },
+ },
+ {
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {},
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": False,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {
+ "days": ["mon", "sun"],
+ "recurrence": "custom_days",
+ "state": "never",
+ "time": None,
+ },
+ },
+ },
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
+ },
+ },
+ {
+ "backup": {
+ "data": {
+ "backups": [],
+ "config": {
+ "agents": {
+ "test-agent1": {"protected": True},
+ "test-agent2": {"protected": False},
+ },
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": False,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {
+ "days": ["mon", "sun"],
+ "recurrence": "custom_days",
+ "state": "never",
+ "time": None,
+ },
+ },
+ },
+ "key": DOMAIN,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
},
},
],
)
+@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600))
async def test_config_info(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
hass_storage: dict[str, Any],
storage_data: dict[str, Any] | None,
) -> None:
"""Test getting backup config info."""
- hass_storage[DOMAIN] = {
- "data": storage_data,
- "key": DOMAIN,
- "version": 1,
- }
+ client = await hass_ws_client(hass)
+ await hass.config.async_set_time_zone("Europe/Amsterdam")
+ freezer.move_to("2024-11-13T12:01:00+01:00")
+
+ hass_storage.update(storage_data)
await setup_backup_integration(hass)
await hass.async_block_till_done()
- client = await hass_ws_client(hass)
-
await client.send_json_auto_id({"type": "backup/config/info"})
assert await client.receive_json() == snapshot
@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups")
@pytest.mark.parametrize(
- "command",
+ "commands",
[
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"copies": None, "days": 7},
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "schedule": "mon",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "schedule": "never",
- },
- {
- "type": "backup/config/update",
- "create_backup": {
- "agent_ids": ["test-agent"],
- "include_addons": ["test-addon"],
- "include_folders": ["media"],
- "name": "test-name",
- "password": "test-password",
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 7},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "schedule": {"recurrence": "daily", "time": "06:00"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "schedule": {"days": ["mon"], "recurrence": "custom_days"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "schedule": {"recurrence": "never"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["test-addon"],
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": 3, "days": 7},
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": None},
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": 3, "days": None},
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 7},
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": 3},
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"days": 7},
+ "schedule": {"recurrence": "daily"},
+ }
+ ],
+ [
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "test-agent1": {"protected": True},
+ "test-agent2": {"protected": False},
+ },
+ }
+ ],
+ [
+ # Test we can update AgentConfig
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "test-agent1": {"protected": True},
+ "test-agent2": {"protected": False},
+ },
},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"copies": 3, "days": 7},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"copies": None, "days": None},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"copies": 3, "days": None},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"copies": None, "days": 7},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"copies": 3},
- "schedule": "daily",
- },
- {
- "type": "backup/config/update",
- "create_backup": {"agent_ids": ["test-agent"]},
- "retention": {"days": 7},
- "schedule": "daily",
- },
+ {
+ "type": "backup/config/update",
+ "agents": {
+ "test-agent1": {"protected": False},
+ "test-agent2": {"protected": True},
+ },
+ },
+ ],
],
)
+@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600))
async def test_config_update(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
- command: dict[str, Any],
+ commands: dict[str, Any],
hass_storage: dict[str, Any],
) -> None:
"""Test updating the backup config."""
+ client = await hass_ws_client(hass)
+ await hass.config.async_set_time_zone("Europe/Amsterdam")
+ freezer.move_to("2024-11-13T12:01:00+01:00")
+
await setup_backup_integration(hass)
await hass.async_block_till_done()
- client = await hass_ws_client(hass)
-
await client.send_json_auto_id({"type": "backup/config/info"})
assert await client.receive_json() == snapshot
- await client.send_json_auto_id(command)
- result = await client.receive_json()
+ for command in commands:
+ await client.send_json_auto_id(command)
+ result = await client.receive_json()
+ assert result["success"]
- assert result["success"]
+ await client.send_json_auto_id({"type": "backup/config/info"})
+ assert await client.receive_json() == snapshot
+ await hass.async_block_till_done()
- await client.send_json_auto_id({"type": "backup/config/info"})
- assert await client.receive_json() == snapshot
+ # Trigger store write
+ freezer.tick(60)
+ async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass_storage[DOMAIN] == snapshot
@@ -1130,7 +1329,32 @@ async def test_config_update(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
- "schedule": "someday",
+ "recurrence": "blah",
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "recurrence": "never",
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "recurrence": {"state": "someday"},
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "recurrence": {"time": "early"},
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "recurrence": {"days": "mon"},
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "recurrence": {"days": ["fun"]},
},
{
"type": "backup/config/update",
@@ -1144,6 +1368,10 @@ async def test_config_update(
"type": "backup/config/update",
"create_backup": {"include_folders": ["media", "media"]},
},
+ {
+ "type": "backup/config/update",
+ "agents": {"test-agent1": {"favorite": True}},
+ },
],
)
async def test_config_update_errors(
@@ -1179,6 +1407,8 @@ async def test_config_update_errors(
"time_2",
"attempted_backup_time",
"completed_backup_time",
+ "scheduled_backup_time",
+ "additional_backup",
"backup_calls_1",
"backup_calls_2",
"call_args",
@@ -1189,10 +1419,12 @@ async def test_config_update_errors(
# No config update
[],
"2024-11-11T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-13T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ False,
1,
2,
BACKUP_CALL,
@@ -1204,14 +1436,16 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
}
],
"2024-11-11T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-13T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ False,
1,
2,
BACKUP_CALL,
@@ -1222,14 +1456,16 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "mon",
+ "schedule": {"days": ["mon"], "recurrence": "custom_days"},
}
],
"2024-11-11T04:45:00+01:00",
- "2024-11-18T04:45:00+01:00",
- "2024-11-25T04:45:00+01:00",
- "2024-11-18T04:45:00+01:00",
- "2024-11-18T04:45:00+01:00",
+ "2024-11-18T04:55:00+01:00",
+ "2024-11-25T04:55:00+01:00",
+ "2024-11-18T04:55:00+01:00",
+ "2024-11-18T04:55:00+01:00",
+ "2024-11-18T04:55:00+01:00",
+ False,
1,
2,
BACKUP_CALL,
@@ -1240,7 +1476,71 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "never",
+ "schedule": {
+ "days": ["mon"],
+ "recurrence": "custom_days",
+ "time": "03:45",
+ },
+ }
+ ],
+ "2024-11-11T03:45:00+01:00",
+ "2024-11-18T03:45:00+01:00",
+ "2024-11-25T03:45:00+01:00",
+ "2024-11-18T03:45:00+01:00",
+ "2024-11-18T03:45:00+01:00",
+ "2024-11-18T03:45:00+01:00",
+ False,
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": {"recurrence": "daily", "time": "03:45"},
+ }
+ ],
+ "2024-11-11T03:45:00+01:00",
+ "2024-11-12T03:45:00+01:00",
+ "2024-11-13T03:45:00+01:00",
+ "2024-11-12T03:45:00+01:00",
+ "2024-11-12T03:45:00+01:00",
+ "2024-11-12T03:45:00+01:00",
+ False,
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": {"days": ["wed", "fri"], "recurrence": "custom_days"},
+ }
+ ],
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-15T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ False,
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": {"recurrence": "never"},
}
],
"2024-11-11T04:45:00+01:00",
@@ -1248,6 +1548,8 @@ async def test_config_update_errors(
"2034-11-11T13:00:00+01:00",
"2024-11-11T04:45:00+01:00",
"2024-11-11T04:45:00+01:00",
+ None,
+ False,
0,
0,
None,
@@ -1258,14 +1560,36 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "daily",
+ "schedule": {"days": [], "recurrence": "custom_days"},
+ }
+ ],
+ "2024-11-11T04:45:00+01:00",
+ "2034-11-11T12:00:00+01:00", # ten years later and still no backups
+ "2034-11-11T13:00:00+01:00",
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T04:45:00+01:00",
+ None,
+ False,
+ 0,
+ 0,
+ None,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": {"recurrence": "daily"},
}
],
"2024-10-26T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-13T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ False,
1,
2,
BACKUP_CALL,
@@ -1276,14 +1600,16 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "mon",
+ "schedule": {"days": ["mon"], "recurrence": "custom_days"},
}
],
"2024-10-26T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-13T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once
- "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once
+ "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once
+ "2024-11-12T04:55:00+01:00",
+ True,
1,
1,
BACKUP_CALL,
@@ -1294,7 +1620,7 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
"2024-10-26T04:45:00+01:00",
@@ -1302,6 +1628,8 @@ async def test_config_update_errors(
"2034-11-12T12:00:00+01:00",
"2024-10-26T04:45:00+01:00",
"2024-10-26T04:45:00+01:00",
+ None,
+ False,
0,
0,
None,
@@ -1312,14 +1640,16 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
}
],
"2024-11-11T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-13T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00", # attempted to create backup but failed
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00", # attempted to create backup but failed
"2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ False,
1,
2,
BACKUP_CALL,
@@ -1330,14 +1660,16 @@ async def test_config_update_errors(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
}
],
"2024-11-11T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00",
- "2024-11-13T04:45:00+01:00",
- "2024-11-12T04:45:00+01:00", # attempted to create backup but failed
+ "2024-11-12T04:55:00+01:00",
+ "2024-11-13T04:55:00+01:00",
+ "2024-11-12T04:55:00+01:00", # attempted to create backup but failed
"2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:55:00+01:00",
+ False,
1,
2,
BACKUP_CALL,
@@ -1345,7 +1677,7 @@ async def test_config_update_errors(
),
],
)
-@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0)
+@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600))
async def test_config_schedule_logic(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -1358,16 +1690,22 @@ async def test_config_schedule_logic(
time_2: str,
attempted_backup_time: str,
completed_backup_time: str,
+ scheduled_backup_time: str,
+ additional_backup: bool,
backup_calls_1: int,
backup_calls_2: int,
call_args: Any,
create_backup_side_effect: list[Exception | None] | None,
) -> None:
"""Test config schedule logic."""
+ created_backup: MagicMock = create_backup.return_value[1].result().backup
+ created_backup.protected = True
+
client = await hass_ws_client(hass)
storage_data = {
- "backups": {},
+ "backups": [],
"config": {
+ "agents": {},
"create_backup": {
"agent_ids": ["test.test-agent"],
"include_addons": ["test-addon"],
@@ -1380,13 +1718,19 @@ async def test_config_schedule_logic(
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": last_completed_automatic_backup,
"last_completed_automatic_backup": last_completed_automatic_backup,
- "schedule": {"state": "daily"},
+ "schedule": {
+ "days": [],
+ "recurrence": "daily",
+ "state": "never",
+ "time": None,
+ },
},
}
hass_storage[DOMAIN] = {
"data": storage_data,
"key": DOMAIN,
- "version": 1,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
}
create_backup.side_effect = create_backup_side_effect
await hass.config.async_set_time_zone("Europe/Amsterdam")
@@ -1400,6 +1744,11 @@ async def test_config_schedule_logic(
result = await client.receive_json()
assert result["success"]
+ await client.send_json_auto_id({"type": "backup/info"})
+ result = await client.receive_json()
+ assert result["result"]["next_automatic_backup"] == scheduled_backup_time
+ assert result["result"]["next_automatic_backup_additional"] == additional_backup
+
freezer.move_to(time_1)
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -1445,7 +1794,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": None, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1484,7 +1833,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 3, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1523,7 +1872,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 3, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1552,7 +1901,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 3, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1596,7 +1945,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 2, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1640,7 +1989,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 2, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1679,7 +2028,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 2, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1718,7 +2067,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 0, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1762,7 +2111,7 @@ async def test_config_schedule_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 0, "days": None},
- "schedule": "daily",
+ "schedule": {"recurrence": "daily"},
},
{
"backup-1": MagicMock(
@@ -1810,10 +2159,14 @@ async def test_config_retention_copies_logic(
delete_args_list: Any,
) -> None:
"""Test config backup retention copies logic."""
+ created_backup: MagicMock = create_backup.return_value[1].result().backup
+ created_backup.protected = True
+
client = await hass_ws_client(hass)
storage_data = {
- "backups": {},
+ "backups": [],
"config": {
+ "agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
@@ -1826,13 +2179,19 @@ async def test_config_retention_copies_logic(
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": last_backup_time,
- "schedule": {"state": "daily"},
+ "schedule": {
+ "days": [],
+ "recurrence": "daily",
+ "state": "never",
+ "time": None,
+ },
},
}
hass_storage[DOMAIN] = {
"data": storage_data,
"key": DOMAIN,
- "version": 1,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
}
get_backups.return_value = (backups, get_backups_agent_errors)
delete_backup.return_value = delete_backup_agent_errors
@@ -1896,7 +2255,7 @@ async def test_config_retention_copies_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": None, "days": None},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
},
{
"backup-1": MagicMock(
@@ -1932,7 +2291,7 @@ async def test_config_retention_copies_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 3, "days": None},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
},
{
"backup-1": MagicMock(
@@ -1968,7 +2327,7 @@ async def test_config_retention_copies_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 3, "days": None},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
},
{
"backup-1": MagicMock(
@@ -2009,7 +2368,7 @@ async def test_config_retention_copies_logic(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"retention": {"copies": 2, "days": None},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
},
{
"backup-1": MagicMock(
@@ -2067,10 +2426,14 @@ async def test_config_retention_copies_logic_manual_backup(
delete_args_list: Any,
) -> None:
"""Test config backup retention copies logic for manual backup."""
+ created_backup: MagicMock = create_backup.return_value[1].result().backup
+ created_backup.protected = True
+
client = await hass_ws_client(hass)
storage_data = {
- "backups": {},
+ "backups": [],
"config": {
+ "agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
@@ -2083,13 +2446,19 @@ async def test_config_retention_copies_logic_manual_backup(
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
- "schedule": {"state": "daily"},
+ "schedule": {
+ "days": [],
+ "recurrence": "daily",
+ "state": "never",
+ "time": None,
+ },
},
}
hass_storage[DOMAIN] = {
"data": storage_data,
"key": DOMAIN,
- "version": 1,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
}
get_backups.return_value = (backups, get_backups_agent_errors)
delete_backup.return_value = delete_backup_agent_errors
@@ -2210,7 +2579,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 2},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2246,7 +2615,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 2},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2282,7 +2651,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 3},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2318,7 +2687,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 2},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2359,7 +2728,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 2},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2395,7 +2764,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 2},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2431,7 +2800,7 @@ async def test_config_retention_copies_logic_manual_backup(
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 0},
- "schedule": "never",
+ "schedule": {"recurrence": "never"},
}
],
{
@@ -2489,8 +2858,9 @@ async def test_config_retention_days_logic(
"""Test config backup retention logic."""
client = await hass_ws_client(hass)
storage_data = {
- "backups": {},
+ "backups": [],
"config": {
+ "agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
@@ -2503,13 +2873,19 @@ async def test_config_retention_days_logic(
"retention": {"copies": None, "days": stored_retained_days},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": last_backup_time,
- "schedule": {"state": "never"},
+ "schedule": {
+ "days": [],
+ "recurrence": "never",
+ "state": "never",
+ "time": None,
+ },
},
}
hass_storage[DOMAIN] = {
"data": storage_data,
"key": DOMAIN,
- "version": 1,
+ "version": store.STORAGE_VERSION,
+ "minor_version": store.STORAGE_VERSION_MINOR,
}
get_backups.return_value = (backups, get_backups_agent_errors)
delete_backup.return_value = delete_backup_agent_errors
@@ -2551,6 +2927,80 @@ async def test_subscribe_event(
assert await client.receive_json() == snapshot
manager.async_on_backup_event(
- CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
+ CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None)
)
assert await client.receive_json() == snapshot
+
+
+@pytest.mark.parametrize(
+ ("agent_id", "backup_id", "password"),
+ [
+ # Invalid agent or backup
+ ("no_such_agent", "c0cb53bd", "hunter2"),
+ ("backup.local", "no_such_backup", "hunter2"),
+ # Legacy backup, which can't be streamed
+ ("backup.local", "2bcb3113", "hunter2"),
+ # New backup, which can be streamed, try with correct and wrong password
+ ("backup.local", "c0cb53bd", "hunter2"),
+ ("backup.local", "c0cb53bd", "wrong_password"),
+ ],
+)
+@pytest.mark.usefixtures("mock_backups")
+async def test_can_decrypt_on_download(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ agent_id: str,
+ backup_id: str,
+ password: str,
+) -> None:
+ """Test can decrypt on download."""
+ await setup_backup_integration(hass, with_hassio=False)
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json_auto_id(
+ {
+ "type": "backup/can_decrypt_on_download",
+ "backup_id": backup_id,
+ "agent_id": agent_id,
+ "password": password,
+ }
+ )
+ assert await client.receive_json() == snapshot
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ BackupAgentError,
+ BackupNotFound,
+ ],
+)
+@pytest.mark.usefixtures("mock_backups")
+async def test_can_decrypt_on_download_with_agent_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ error: Exception,
+) -> None:
+ """Test can decrypt on download."""
+
+ await setup_backup_integration(
+ hass,
+ with_hassio=False,
+ backups={"test.remote": [TEST_BACKUP_ABC123]},
+ remote_agents=["remote"],
+ )
+ client = await hass_ws_client(hass)
+
+ with patch.object(BackupAgentTest, "async_download_backup", side_effect=error):
+ await client.send_json_auto_id(
+ {
+ "type": "backup/can_decrypt_on_download",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": "test.remote",
+ "password": "hunter2",
+ }
+ )
+ assert await client.receive_json() == snapshot
diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py
index 765801d22cf..14810f67ce3 100644
--- a/tests/components/baf/test_config_flow.py
+++ b/tests/components/baf/test_config_flow.py
@@ -4,11 +4,11 @@ from ipaddress import ip_address
from unittest.mock import patch
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.baf.const import DOMAIN
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import MOCK_NAME, MOCK_UUID, MockBAFDevice
@@ -90,7 +90,7 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -128,7 +128,7 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -148,7 +148,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"),
ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")],
hostname="mock_hostname",
@@ -167,7 +167,7 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non
discovery_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py
index afa170577df..d81edaad3b4 100644
--- a/tests/components/balboa/test_config_flow.py
+++ b/tests/components/balboa/test_config_flow.py
@@ -3,19 +3,23 @@
from unittest.mock import MagicMock, patch
from pybalboa.exceptions import SpaConnectionError
+import pytest
from homeassistant import config_entries
from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from tests.common import MockConfigEntry
-TEST_DATA = {
- CONF_HOST: "1.1.1.1",
-}
-TEST_ID = "FakeBalboa"
+TEST_HOST = "1.1.1.1"
+TEST_DATA = {CONF_HOST: TEST_HOST}
+TEST_MAC = "ef:ef:ef:c0:ff:ee"
+TEST_DHCP_SERVICE_INFO = DhcpServiceInfo(
+ ip=TEST_HOST, macaddress=TEST_MAC.replace(":", ""), hostname="fakespa"
+)
async def test_form(hass: HomeAssistant, client: MagicMock) -> None:
@@ -107,7 +111,7 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None:
async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None:
"""Test when provided credentials are already configured."""
- MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass)
+ MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -138,7 +142,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non
async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None:
"""Test specifying non default settings using options flow."""
- config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID)
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -161,3 +165,111 @@ async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert dict(config_entry.options) == {CONF_SYNC_TIME: True}
+
+
+async def test_dhcp_discovery(hass: HomeAssistant, client: MagicMock) -> None:
+ """Test we can process the discovery from dhcp."""
+ with patch(
+ "homeassistant.components.balboa.config_flow.SpaClient.__aenter__",
+ return_value=client,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_DHCP},
+ data=TEST_DHCP_SERVICE_INFO,
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ await hass.async_block_till_done()
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "FakeSpa"
+ assert result["data"] == TEST_DATA
+ assert result["result"].unique_id == TEST_MAC
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_DHCP},
+ data=TEST_DHCP_SERVICE_INFO,
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_dhcp_discovery_updates_host(
+ hass: HomeAssistant, client: MagicMock
+) -> None:
+ """Test dhcp discovery updates host and aborts."""
+ entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC)
+ entry.add_to_hass(hass)
+
+ updated_ip = "1.1.1.2"
+ TEST_DHCP_SERVICE_INFO.ip = updated_ip
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_DHCP},
+ data=TEST_DHCP_SERVICE_INFO,
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+ assert entry.data[CONF_HOST] == updated_ip
+
+
+@pytest.mark.parametrize(
+ ("side_effect", "reason"),
+ [
+ (SpaConnectionError, "cannot_connect"),
+ (Exception, "unknown"),
+ ],
+)
+async def test_dhcp_discovery_failed(
+ hass: HomeAssistant, client: MagicMock, side_effect: Exception, reason: str
+) -> None:
+ """Test failed setup from dhcp."""
+ with patch(
+ "homeassistant.components.balboa.config_flow.SpaClient.__aenter__",
+ return_value=client,
+ side_effect=side_effect(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_DHCP},
+ data=TEST_DHCP_SERVICE_INFO,
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == reason
+
+
+async def test_dhcp_discovery_manual_user_setup(
+ hass: HomeAssistant, client: MagicMock
+) -> None:
+ """Test dhcp discovery with manual user setup."""
+ with patch(
+ "homeassistant.components.balboa.config_flow.SpaClient.__aenter__",
+ return_value=client,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_DHCP},
+ data=TEST_DHCP_SERVICE_INFO,
+ )
+
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ )
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ TEST_DATA,
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == TEST_DATA
diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py
index cbde856ff89..700d085dd11 100644
--- a/tests/components/bang_olufsen/conftest.py
+++ b/tests/components/bang_olufsen/conftest.py
@@ -56,7 +56,7 @@ from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
- """Mock config entry."""
+ """Mock config entry for Beosound Balance."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SERIAL_NUMBER,
@@ -66,8 +66,8 @@ def mock_config_entry() -> MockConfigEntry:
@pytest.fixture
-def mock_config_entry_2() -> MockConfigEntry:
- """Mock config entry."""
+def mock_config_entry_core() -> MockConfigEntry:
+ """Mock config entry for Beoconnect Core."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SERIAL_NUMBER_2,
diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py
index 6602a898eb6..c21afb4a130 100644
--- a/tests/components/bang_olufsen/const.py
+++ b/tests/components/bang_olufsen/const.py
@@ -31,28 +31,29 @@ from homeassistant.components.bang_olufsen.const import (
CONF_BEOLINK_JID,
BangOlufsenSource,
)
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
TEST_HOST = "192.168.0.1"
TEST_HOST_INVALID = "192.168.0"
TEST_HOST_IPV6 = "1111:2222:3333:4444:5555:6666:7777:8888"
TEST_MODEL_BALANCE = "Beosound Balance"
+TEST_MODEL_CORE = "Beoconnect Core"
TEST_MODEL_THEATRE = "Beosound Theatre"
TEST_MODEL_LEVEL = "Beosound Level"
TEST_SERIAL_NUMBER = "11111111"
-TEST_SERIAL_NUMBER_2 = "22222222"
TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}"
-TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}"
TEST_FRIENDLY_NAME = "Living room Balance"
TEST_TYPE_NUMBER = "1111"
TEST_ITEM_NUMBER = "1111111"
TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com"
TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111"
-TEST_FRIENDLY_NAME_2 = "Laundry room Balance"
-TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com"
-TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222"
+TEST_FRIENDLY_NAME_2 = "Laundry room Core"
+TEST_SERIAL_NUMBER_2 = "22222222"
+TEST_NAME_2 = f"{TEST_MODEL_CORE}-{TEST_SERIAL_NUMBER_2}"
+TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@products.bang-olufsen.com"
+TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222"
TEST_HOST_2 = "192.168.0.2"
TEST_FRIENDLY_NAME_3 = "Lego room Balance"
@@ -65,6 +66,9 @@ TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-oluf
TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444"
TEST_HOST_4 = "192.168.0.4"
+
+TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause"
+
TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local."
TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local."
TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF
@@ -80,8 +84,8 @@ TEST_DATA_CREATE_ENTRY = {
CONF_NAME: TEST_NAME,
}
TEST_DATA_CREATE_ENTRY_2 = {
- CONF_HOST: TEST_HOST,
- CONF_MODEL: TEST_MODEL_BALANCE,
+ CONF_HOST: TEST_HOST_2,
+ CONF_MODEL: TEST_MODEL_CORE,
CONF_BEOLINK_JID: TEST_JID_2,
CONF_NAME: TEST_NAME_2,
}
diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr
new file mode 100644
index 00000000000..3b748d3a27a
--- /dev/null
+++ b/tests/components/bang_olufsen/snapshots/test_event.ambr
@@ -0,0 +1,21 @@
+# serializer version: 1
+# name: test_button_event_creation
+ list([
+ 'event.beosound_balance_11111111_bluetooth',
+ 'event.beosound_balance_11111111_microphone',
+ 'event.beosound_balance_11111111_next',
+ 'event.beosound_balance_11111111_play_pause',
+ 'event.beosound_balance_11111111_favourite_1',
+ 'event.beosound_balance_11111111_favourite_2',
+ 'event.beosound_balance_11111111_favourite_3',
+ 'event.beosound_balance_11111111_favourite_4',
+ 'event.beosound_balance_11111111_previous',
+ 'event.beosound_balance_11111111_volume',
+ 'media_player.beosound_balance_11111111',
+ ])
+# ---
+# name: test_button_event_creation_beoconnect_core
+ list([
+ 'media_player.beoconnect_core_22222222',
+ ])
+# ---
diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
index 327b7ecfacf..be7989a2cb9 100644
--- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr
+++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
@@ -642,7 +642,7 @@
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
- 'media_player.beosound_balance_22222222',
+ 'media_player.beoconnect_core_22222222',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
@@ -661,7 +661,7 @@
'supported_features': ,
}),
'context': ,
- 'entity_id': 'media_player.beosound_balance_22222222',
+ 'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
@@ -737,7 +737,7 @@
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
- 'media_player.beosound_balance_22222222',
+ 'media_player.beoconnect_core_22222222',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
@@ -756,7 +756,7 @@
'supported_features': ,
}),
'context': ,
- 'entity_id': 'media_player.beosound_balance_22222222',
+ 'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
@@ -831,7 +831,7 @@
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
- 'media_player.beosound_balance_22222222',
+ 'media_player.beoconnect_core_22222222',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
@@ -850,7 +850,7 @@
'supported_features': ,
}),
'context': ,
- 'entity_id': 'media_player.beosound_balance_22222222',
+ 'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
@@ -924,7 +924,7 @@
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
- 'media_player.beosound_balance_22222222',
+ 'media_player.beoconnect_core_22222222',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
@@ -943,7 +943,7 @@
'supported_features': ,
}),
'context': ,
- 'entity_id': 'media_player.beosound_balance_22222222',
+ 'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
@@ -1003,7 +1003,7 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'leader': dict({
- 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com',
+ 'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com',
}),
'peers': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
@@ -1017,7 +1017,7 @@
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
- 'media_player.beosound_balance_22222222',
+ 'media_player.beoconnect_core_22222222',
'media_player.beosound_balance_11111111',
]),
'media_content_type': ,
@@ -1062,7 +1062,7 @@
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
- 'media_player.beosound_balance_22222222',
+ 'media_player.beoconnect_core_22222222',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
@@ -1081,7 +1081,7 @@
'supported_features': ,
}),
'context': ,
- 'entity_id': 'media_player.beosound_balance_22222222',
+ 'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py
new file mode 100644
index 00000000000..855dab40db1
--- /dev/null
+++ b/tests/components/bang_olufsen/test_event.py
@@ -0,0 +1,108 @@
+"""Test the bang_olufsen event entities."""
+
+from unittest.mock import AsyncMock
+
+from inflection import underscore
+from mozart_api.models import ButtonEvent
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.bang_olufsen.const import (
+ DEVICE_BUTTON_EVENTS,
+ DEVICE_BUTTONS,
+ EVENT_TRANSLATION_MAP,
+)
+from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES
+from homeassistant.const import STATE_UNKNOWN
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_registry import EntityRegistry
+
+from .const import TEST_BUTTON_EVENT_ENTITY_ID
+
+from tests.common import MockConfigEntry
+
+
+async def test_button_event_creation(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
+ entity_registry: EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test button event entities are created."""
+
+ # Load entry
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ # Add Button Event entity ids
+ entity_ids = [
+ f"event.beosound_balance_11111111_{underscore(button_type)}".replace(
+ "preset", "favourite_"
+ )
+ for button_type in DEVICE_BUTTONS
+ ]
+
+ # Check that the entities are available
+ for entity_id in entity_ids:
+ assert entity_registry.async_get(entity_id)
+
+ # Check number of entities
+ # The media_player entity and all of the button event entities should be the only available
+ entity_ids_available = list(entity_registry.entities.keys())
+ assert len(entity_ids_available) == 1 + len(entity_ids)
+
+ assert entity_ids_available == snapshot
+
+
+async def test_button_event_creation_beoconnect_core(
+ hass: HomeAssistant,
+ mock_config_entry_core: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
+ entity_registry: EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test button event entities are not created when using a Beoconnect Core."""
+
+ # Load entry
+ mock_config_entry_core.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
+
+ # Check number of entities
+ # The media_player entity should be the only available
+ entity_ids_available = list(entity_registry.entities.keys())
+ assert len(entity_ids_available) == 1
+
+ assert entity_ids_available == snapshot
+
+
+async def test_button(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
+ entity_registry: EntityRegistry,
+) -> None:
+ """Test button event entity."""
+ # Load entry
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ # Enable the entity
+ entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None)
+ hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
+
+ assert (states := hass.states.get(TEST_BUTTON_EVENT_ENTITY_ID))
+ assert states.state is STATE_UNKNOWN
+ assert states.attributes[ATTR_EVENT_TYPES] == list(DEVICE_BUTTON_EVENTS)
+
+ # Check button reacts as expected to WebSocket events
+ notification_callback = mock_mozart_client.get_button_notifications.call_args[0][0]
+
+ notification_callback(ButtonEvent(button="PlayPause", state="shortPress (Release)"))
+ await hass.async_block_till_done()
+
+ assert (states := hass.states.get(TEST_BUTTON_EVENT_ENTITY_ID))
+ assert states.state is not None
+ assert (
+ states.attributes[ATTR_EVENT_TYPE]
+ == EVENT_TRANSLATION_MAP["shortPress (Release)"]
+ )
diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py
index 695b086b0a7..70b826f0b92 100644
--- a/tests/components/bang_olufsen/test_media_player.py
+++ b/tests/components/bang_olufsen/test_media_player.py
@@ -528,7 +528,7 @@ async def test_async_update_beolink_listener(
snapshot: SnapshotAssertion,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
- mock_config_entry_2: MockConfigEntry,
+ mock_config_entry_core: MockConfigEntry,
) -> None:
"""Test _async_update_beolink as a listener."""
@@ -540,8 +540,8 @@ async def test_async_update_beolink_listener(
)
# Add another entity
- mock_config_entry_2.add_to_hass(hass)
- await hass.config_entries.async_setup(mock_config_entry_2.entry_id)
+ mock_config_entry_core.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
# Runs _async_update_beolink
playback_metadata_callback(
@@ -1386,7 +1386,7 @@ async def test_async_join_players(
snapshot: SnapshotAssertion,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
- mock_config_entry_2: MockConfigEntry,
+ mock_config_entry_core: MockConfigEntry,
group_members: list[str],
expand_count: int,
join_count: int,
@@ -1401,8 +1401,8 @@ async def test_async_join_players(
)
# Add another entity
- mock_config_entry_2.add_to_hass(hass)
- await hass.config_entries.async_setup(mock_config_entry_2.entry_id)
+ mock_config_entry_core.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
# Set the source to a beolink expandable source
source_change_callback(TEST_SOURCE)
@@ -1453,7 +1453,7 @@ async def test_async_join_players_invalid(
snapshot: SnapshotAssertion,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
- mock_config_entry_2: MockConfigEntry,
+ mock_config_entry_core: MockConfigEntry,
source: Source,
group_members: list[str],
expected_result: AbstractContextManager,
@@ -1468,8 +1468,8 @@ async def test_async_join_players_invalid(
mock_mozart_client.get_source_change_notifications.call_args[0][0]
)
- mock_config_entry_2.add_to_hass(hass)
- await hass.config_entries.async_setup(mock_config_entry_2.entry_id)
+ mock_config_entry_core.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
source_change_callback(source)
diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py
index 8a0132ff2af..59fbdf9a253 100644
--- a/tests/components/binary_sensor/test_device_condition.py
+++ b/tests/components/binary_sensor/test_device_condition.py
@@ -14,7 +14,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .common import MockBinarySensor
diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py
index 78e382f77bf..dd71c1e5d06 100644
--- a/tests/components/binary_sensor/test_device_trigger.py
+++ b/tests/components/binary_sensor/test_device_trigger.py
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .common import MockBinarySensor
diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py
index db92dddcc77..5de41a1fb1e 100644
--- a/tests/components/blackbird/test_media_player.py
+++ b/tests/components/blackbird/test_media_player.py
@@ -10,7 +10,6 @@ from homeassistant.components.blackbird.const import DOMAIN, SERVICE_SETALLZONES
from homeassistant.components.blackbird.media_player import (
DATA_BLACKBIRD,
PLATFORM_SCHEMA,
- setup_platform,
)
from homeassistant.components.media_player import (
MediaPlayerEntity,
@@ -18,6 +17,9 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockEntityPlatform
class AttrDict(dict):
@@ -181,21 +183,21 @@ async def setup_blackbird(hass: HomeAssistant, mock_blackbird: MockBlackbird) ->
"homeassistant.components.blackbird.media_player.get_blackbird",
return_value=mock_blackbird,
):
- await hass.async_add_executor_job(
- setup_platform,
+ await async_setup_component(
hass,
+ "media_player",
{
- "platform": "blackbird",
- "port": "/dev/ttyUSB0",
- "zones": {3: {"name": "Zone name"}},
- "sources": {
- 1: {"name": "one"},
- 3: {"name": "three"},
- 2: {"name": "two"},
- },
+ "media_player": {
+ "platform": "blackbird",
+ "port": "/dev/ttyUSB0",
+ "zones": {3: {"name": "Zone name"}},
+ "sources": {
+ 1: {"name": "one"},
+ 3: {"name": "three"},
+ 2: {"name": "two"},
+ },
+ }
},
- lambda *args, **kwargs: None,
- {},
)
await hass.async_block_till_done()
@@ -207,6 +209,7 @@ def media_player_entity(
"""Return the media player entity."""
media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"]
media_player.hass = hass
+ media_player.platform = MockEntityPlatform(hass)
media_player.entity_id = "media_player.zone_3"
return media_player
@@ -271,10 +274,6 @@ async def test_update(
hass: HomeAssistant, media_player_entity: MediaPlayerEntity
) -> None:
"""Test updating values from blackbird."""
- assert media_player_entity.state is None
- assert media_player_entity.source is None
-
- await hass.async_add_executor_job(media_player_entity.update)
assert media_player_entity.state == STATE_ON
assert media_player_entity.source == "one"
@@ -291,9 +290,6 @@ async def test_state(
mock_blackbird: MockBlackbird,
) -> None:
"""Test state property."""
- assert media_player_entity.state is None
-
- await hass.async_add_executor_job(media_player_entity.update)
assert media_player_entity.state == STATE_ON
mock_blackbird.zones[3].power = False
@@ -315,8 +311,6 @@ async def test_source(
hass: HomeAssistant, media_player_entity: MediaPlayerEntity
) -> None:
"""Test source property."""
- assert media_player_entity.source is None
- await hass.async_add_executor_job(media_player_entity.update)
assert media_player_entity.source == "one"
@@ -324,8 +318,6 @@ async def test_media_title(
hass: HomeAssistant, media_player_entity: MediaPlayerEntity
) -> None:
"""Test media title property."""
- assert media_player_entity.media_title is None
- await hass.async_add_executor_job(media_player_entity.update)
assert media_player_entity.media_title == "one"
diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py
index 612c4f09424..4b0c1b23e79 100644
--- a/tests/components/blebox/test_config_flow.py
+++ b/tests/components/blebox/test_config_flow.py
@@ -7,12 +7,12 @@ import blebox_uniapi
import pytest
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.blebox import config_flow
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.setup import async_setup_component
from .conftest import mock_config, mock_feature, mock_only_feature, setup_product_mock
@@ -227,7 +227,7 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("172.100.123.4"),
ip_addresses=[ip_address("172.100.123.4")],
port=80,
@@ -267,7 +267,7 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) -
result2 = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("172.100.123.4"),
ip_addresses=[ip_address("172.100.123.4")],
port=80,
@@ -291,7 +291,7 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) -
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("172.100.123.4"),
ip_addresses=[ip_address("172.100.123.4")],
port=80,
@@ -317,7 +317,7 @@ async def test_flow_with_zeroconf_when_device_response_unsupported(
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("172.100.123.4"),
ip_addresses=[ip_address("172.100.123.4")],
port=80,
diff --git a/tests/components/blueprint/test_default_blueprints.py b/tests/components/blueprint/test_default_blueprints.py
index f69126a7f25..fbbd48eedd3 100644
--- a/tests/components/blueprint/test_default_blueprints.py
+++ b/tests/components/blueprint/test_default_blueprints.py
@@ -8,7 +8,7 @@ import pytest
from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, models
from homeassistant.components.blueprint.const import BLUEPRINT_FOLDER
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
DOMAINS = ["automation"]
LOGGER = logging.getLogger(__name__)
@@ -25,5 +25,5 @@ def test_default_blueprints(domain: str) -> None:
for fil in items:
LOGGER.info("Processing %s", fil)
assert fil.name.endswith(".yaml")
- data = yaml.load_yaml(fil)
+ data = yaml_util.load_yaml(fil)
models.Blueprint(data, expected_domain=domain, schema=BLUEPRINT_SCHEMA)
diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr
index 3e644d3038a..f71302f286d 100644
--- a/tests/components/bluesound/snapshots/test_media_player.ambr
+++ b/tests/components/bluesound/snapshots/test_media_player.ambr
@@ -9,7 +9,6 @@
'media_artist': 'artist',
'media_content_type': ,
'media_duration': 123,
- 'media_position': 2,
'media_title': 'song',
'shuffle': False,
'source_list': list([
diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py
index 63744cdf0ff..d0e0f75991b 100644
--- a/tests/components/bluesound/test_config_flow.py
+++ b/tests/components/bluesound/test_config_flow.py
@@ -5,11 +5,11 @@ from unittest.mock import AsyncMock
from pyblu.errors import PlayerUnreachableError
from homeassistant.components.bluesound.const import DOMAIN
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
-from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF
+from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .conftest import PlayerMocks
@@ -113,63 +113,6 @@ async def test_user_flow_aleady_configured(
player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once()
-async def test_import_flow_success(
- hass: HomeAssistant, mock_setup_entry: AsyncMock, player_mocks: PlayerMocks
-) -> None:
- """Test we get the form."""
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000},
- )
-
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == "player-name1111"
- assert result["data"] == {CONF_HOST: "1.1.1.1", CONF_PORT: 11000}
- assert result["result"].unique_id == "ff:ff:01:01:01:01-11000"
-
- mock_setup_entry.assert_called_once()
- player_mocks.player_data.player.sync_status.assert_called_once()
-
-
-async def test_import_flow_cannot_connect(
- hass: HomeAssistant, player_mocks: PlayerMocks
-) -> None:
- """Test we handle cannot connect error."""
- player_mocks.player_data.player.sync_status.side_effect = PlayerUnreachableError(
- "Player not reachable"
- )
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000},
- )
-
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "cannot_connect"
-
- player_mocks.player_data.player.sync_status.assert_called_once()
-
-
-async def test_import_flow_already_configured(
- hass: HomeAssistant,
- player_mocks: PlayerMocks,
- config_entry: MockConfigEntry,
-) -> None:
- """Test we handle already configured."""
- config_entry.add_to_hass(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={CONF_HOST: "1.1.1.2", CONF_PORT: 11000},
- )
-
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "already_configured"
-
- player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once()
-
-
async def test_zeroconf_flow_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock, player_mocks: PlayerMocks
) -> None:
diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py
index a43696a0a7f..ed537d0bc57 100644
--- a/tests/components/bluesound/test_media_player.py
+++ b/tests/components/bluesound/test_media_player.py
@@ -127,7 +127,9 @@ async def test_attributes_set(
) -> None:
"""Test the media player attributes set."""
state = hass.states.get("media_player.player_name1111")
- assert state == snapshot(exclude=props("media_position_updated_at"))
+ assert state == snapshot(
+ exclude=props("media_position_updated_at", "media_position")
+ )
async def test_stop_maps_to_idle(
diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py
index 8794d808718..31d301e2dac 100644
--- a/tests/components/bluetooth/__init__.py
+++ b/tests/components/bluetooth/__init__.py
@@ -10,32 +10,35 @@ from unittest.mock import MagicMock, patch
from bleak import BleakClient
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bluetooth_adapters import DEFAULT_ADDRESS
-from habluetooth import BaseHaScanner, BluetoothManager, get_manager
+from habluetooth import BaseHaScanner, get_manager
from homeassistant.components.bluetooth import (
DOMAIN,
+ MONOTONIC_TIME,
SOURCE_LOCAL,
+ BaseHaRemoteScanner,
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_get_advertisement_callback,
)
+from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
__all__ = (
+ "MockBleakClient",
+ "generate_advertisement_data",
+ "generate_ble_device",
"inject_advertisement",
"inject_advertisement_with_source",
"inject_advertisement_with_time_and_source",
"inject_advertisement_with_time_and_source_connectable",
"inject_bluetooth_service_info",
"patch_all_discovered_devices",
- "patch_discovered_devices",
- "generate_advertisement_data",
- "generate_ble_device",
- "MockBleakClient",
"patch_bluetooth_time",
+ "patch_discovered_devices",
)
ADVERTISEMENT_DATA_DEFAULTS = {
@@ -55,6 +58,11 @@ BLE_DEVICE_DEFAULTS = {
}
+HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00"
+HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11"
+NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF"
+
+
@contextmanager
def patch_bluetooth_time(mock_time: float) -> None:
"""Patch the bluetooth time."""
@@ -99,9 +107,10 @@ def generate_ble_device(
return BLEDevice(**new)
-def _get_manager() -> BluetoothManager:
+def _get_manager() -> HomeAssistantBluetoothManager:
"""Return the bluetooth manager."""
- return get_manager()
+ manager: HomeAssistantBluetoothManager = get_manager()
+ return manager
def inject_advertisement(
@@ -324,3 +333,26 @@ class FakeScanner(FakeScannerMixin, BaseHaScanner):
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
return {}
+
+
+class FakeRemoteScanner(BaseHaRemoteScanner):
+ """Fake remote scanner."""
+
+ def inject_advertisement(
+ self,
+ device: BLEDevice,
+ advertisement_data: AdvertisementData,
+ now: float | None = None,
+ ) -> None:
+ """Inject an advertisement."""
+ self._async_on_advertisement(
+ device.address,
+ advertisement_data.rssi,
+ device.name,
+ advertisement_data.service_uuids,
+ advertisement_data.service_data,
+ advertisement_data.manufacturer_data,
+ advertisement_data.tx_power,
+ {"scanner_specific_data": "test"},
+ now or MONOTONIC_TIME(),
+ )
diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py
index 93a1c59cba1..e07b580acb2 100644
--- a/tests/components/bluetooth/conftest.py
+++ b/tests/components/bluetooth/conftest.py
@@ -5,9 +5,20 @@ from unittest.mock import patch
from bleak_retry_connector import bleak_manager
from dbus_fast.aio import message_bus
+from habluetooth import BaseHaRemoteScanner
import habluetooth.util as habluetooth_utils
import pytest
+from homeassistant.components import bluetooth
+from homeassistant.core import HomeAssistant
+
+from . import (
+ HCI0_SOURCE_ADDRESS,
+ HCI1_SOURCE_ADDRESS,
+ NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
+ FakeScanner,
+)
+
@pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package")
def disable_bluez_manager_socket():
@@ -304,3 +315,37 @@ def disable_new_discovery_flows_fixture():
"homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow"
) as mock_create_flow:
yield mock_create_flow
+
+
+@pytest.fixture
+def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]:
+ """Register an hci0 scanner."""
+ hci0_scanner = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0")
+ hci0_scanner.connectable = True
+ cancel = bluetooth.async_register_scanner(hass, hci0_scanner, connection_slots=5)
+ yield
+ cancel()
+ bluetooth.async_remove_scanner(hass, hci0_scanner.source)
+
+
+@pytest.fixture
+def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]:
+ """Register an hci1 scanner."""
+ hci1_scanner = FakeScanner(HCI1_SOURCE_ADDRESS, "hci1")
+ hci1_scanner.connectable = True
+ cancel = bluetooth.async_register_scanner(hass, hci1_scanner, connection_slots=5)
+ yield
+ cancel()
+ bluetooth.async_remove_scanner(hass, hci1_scanner.source)
+
+
+@pytest.fixture
+def register_non_connectable_scanner(hass: HomeAssistant) -> Generator[None]:
+ """Register an non connectable remote scanner."""
+ remote_scanner = BaseHaRemoteScanner(
+ NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, "non connectable", None, False
+ )
+ cancel = bluetooth.async_register_scanner(hass, remote_scanner)
+ yield
+ cancel()
+ bluetooth.async_remove_scanner(hass, remote_scanner.source)
diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py
index abfbbaa15ab..acd630863d2 100644
--- a/tests/components/bluetooth/test_base_scanner.py
+++ b/tests/components/bluetooth/test_base_scanner.py
@@ -7,16 +7,12 @@ import time
from typing import Any
from unittest.mock import patch
-from bleak.backends.device import BLEDevice
-from bleak.backends.scanner import AdvertisementData
-
# pylint: disable-next=no-name-in-module
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
import pytest
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
- MONOTONIC_TIME,
BaseHaRemoteScanner,
HaBluetoothConnector,
storage,
@@ -28,12 +24,16 @@ from homeassistant.components.bluetooth.const import (
SCANNER_WATCHDOG_TIMEOUT,
UNAVAILABLE_TRACK_SECONDS,
)
+from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.json import json_loads
from . import (
+ FakeRemoteScanner as FakeScanner,
MockBleakClient,
_get_manager,
generate_advertisement_data,
@@ -41,30 +41,7 @@ from . import (
patch_bluetooth_time,
)
-from tests.common import async_fire_time_changed, load_fixture
-
-
-class FakeScanner(BaseHaRemoteScanner):
- """Fake scanner."""
-
- def inject_advertisement(
- self,
- device: BLEDevice,
- advertisement_data: AdvertisementData,
- now: float | None = None,
- ) -> None:
- """Inject an advertisement."""
- self._async_on_advertisement(
- device.address,
- advertisement_data.rssi,
- device.name,
- advertisement_data.service_uuids,
- advertisement_data.service_data,
- advertisement_data.manufacturer_data,
- advertisement_data.tx_power,
- {"scanner_specific_data": "test"},
- now or MONOTONIC_TIME(),
- )
+from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
@pytest.mark.parametrize("name_2", [None, "w"])
@@ -545,3 +522,75 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None:
cancel()
unsetup()
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+@pytest.mark.parametrize(
+ ("manufacturer", "source"),
+ [
+ ("test", "test"),
+ ("Raspberry Pi Trading Ltd (test)", "28:CD:C1:11:23:45"),
+ ],
+)
+async def test_remote_scanner_bluetooth_config_entry(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ manufacturer: str,
+ source: str,
+) -> None:
+ """Test the remote scanner gets a bluetooth config entry."""
+ manager: HomeAssistantBluetoothManager = _get_manager()
+
+ switchbot_device = generate_ble_device(
+ "44:44:33:11:23:45",
+ "wohand",
+ {},
+ rssi=-100,
+ )
+ switchbot_device_adv = generate_advertisement_data(
+ local_name="wohand",
+ service_uuids=[],
+ manufacturer_data={1: b"\x01"},
+ rssi=-100,
+ )
+
+ connector = (
+ HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
+ )
+ scanner = FakeScanner(source, source, connector, True)
+ unsetup = scanner.async_setup()
+ assert scanner.source == source
+ entry = MockConfigEntry(domain="test")
+ entry.add_to_hass(hass)
+ cancel = manager.async_register_hass_scanner(
+ scanner,
+ source_domain="test",
+ source_model="test",
+ source_config_entry_id=entry.entry_id,
+ )
+ await hass.async_block_till_done()
+
+ scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
+ assert len(scanner.discovered_devices) == 1
+
+ cancel()
+ unsetup()
+
+ adapter_entry = hass.config_entries.async_entry_for_domain_unique_id(
+ "bluetooth", scanner.source
+ )
+ assert adapter_entry is not None
+ assert adapter_entry.state is ConfigEntryState.LOADED
+
+ dev = device_registry.async_get_device(
+ connections={(dr.CONNECTION_BLUETOOTH, scanner.source)}
+ )
+ assert dev is not None
+ assert dev.config_entries == {adapter_entry.entry_id}
+ assert dev.manufacturer == manufacturer
+
+ manager.async_remove_scanner(scanner.source)
+ await hass.async_block_till_done()
+ assert not hass.config_entries.async_entry_for_domain_unique_id(
+ "bluetooth", scanner.source
+ )
diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py
index 0a0cb3fa8e0..f0136396c22 100644
--- a/tests/components/bluetooth/test_config_flow.py
+++ b/tests/components/bluetooth/test_config_flow.py
@@ -6,16 +6,25 @@ from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails
import pytest
from homeassistant import config_entries
+from homeassistant.components.bluetooth import HaBluetoothConnector
from homeassistant.components.bluetooth.const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_PASSIVE,
+ CONF_SOURCE,
+ CONF_SOURCE_CONFIG_ENTRY_ID,
+ CONF_SOURCE_DEVICE_ID,
+ CONF_SOURCE_DOMAIN,
+ CONF_SOURCE_MODEL,
DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers import area_registry as ar, device_registry as dr
from homeassistant.setup import async_setup_component
+from . import FakeRemoteScanner, MockBleakClient, _get_manager
+
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@@ -450,9 +459,68 @@ async def test_options_flow_enabled_linux(
await hass.config_entries.async_unload(entry.entry_id)
+@pytest.mark.usefixtures(
+ "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters"
+)
+async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None:
+ """Test options are not available for remote adapters."""
+ source_entry = MockConfigEntry(
+ domain="test",
+ )
+ source_entry.add_to_hass(hass)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_SOURCE: "BB:BB:BB:BB:BB:BB",
+ CONF_SOURCE_DOMAIN: "test",
+ CONF_SOURCE_MODEL: "test",
+ CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id,
+ },
+ options={},
+ unique_id="BB:BB:BB:BB:BB:BB",
+ )
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "remote_adapters_not_supported"
+
+
+@pytest.mark.usefixtures(
+ "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters"
+)
+async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> None:
+ """Test options are not available for local adapters without passive support."""
+ source_entry = MockConfigEntry(
+ domain="test",
+ )
+ source_entry.add_to_hass(hass)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={},
+ options={},
+ unique_id="BB:BB:BB:BB:BB:BB",
+ )
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ _get_manager()._adapters["hci0"]["passive_scan"] = False
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "local_adapters_no_passive_support"
+
+
@pytest.mark.usefixtures("one_adapter")
-async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None:
- """Test we give a hint that the adapter is ignored."""
+async def test_async_step_user_linux_adapter_replace_ignored(
+ hass: HomeAssistant,
+) -> None:
+ """Test we can replace an ignored adapter from user flow."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="00:00:00:00:00:01",
@@ -464,6 +532,79 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) ->
context={"source": config_entries.SOURCE_USER},
data={},
)
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "no_adapters"
- assert result["description_placeholders"] == {"ignored_adapters": "1"}
+ with (
+ patch("homeassistant.components.bluetooth.async_setup", return_value=True),
+ patch(
+ "homeassistant.components.bluetooth.async_setup_entry", return_value=True
+ ) as mock_setup_entry,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result2["type"] is FlowResultType.CREATE_ENTRY
+ assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)"
+ assert result2["data"] == {}
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_async_step_integration_discovery_remote_adapter(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ area_registry: ar.AreaRegistry,
+) -> None:
+ """Test remote adapter configuration via integration discovery."""
+ entry = MockConfigEntry(domain="test")
+ entry.add_to_hass(hass)
+ connector = (
+ HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
+ )
+ scanner = FakeRemoteScanner("esp32", "esp32", connector, True)
+ manager = _get_manager()
+ area_entry = area_registry.async_get_or_create("test")
+ cancel_scanner = manager.async_register_scanner(scanner)
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={("test", "BB:BB:BB:BB:BB:BB")},
+ suggested_area=area_entry.id,
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
+ data={
+ CONF_SOURCE: scanner.source,
+ CONF_SOURCE_DOMAIN: "test",
+ CONF_SOURCE_MODEL: "test",
+ CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id,
+ CONF_SOURCE_DEVICE_ID: device_entry.id,
+ },
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "esp32"
+ assert result["data"] == {
+ CONF_SOURCE: scanner.source,
+ CONF_SOURCE_DOMAIN: "test",
+ CONF_SOURCE_MODEL: "test",
+ CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id,
+ CONF_SOURCE_DEVICE_ID: device_entry.id,
+ }
+ await hass.async_block_till_done()
+
+ new_entry_id: str = result["result"].entry_id
+ new_entry = hass.config_entries.async_get_entry(new_entry_id)
+ assert new_entry is not None
+ assert new_entry.state is config_entries.ConfigEntryState.LOADED
+
+ ble_device_entry = device_registry.async_get_device(
+ connections={(dr.CONNECTION_BLUETOOTH, scanner.source)}
+ )
+ assert ble_device_entry is not None
+ assert ble_device_entry.via_device_id == device_entry.id
+ assert ble_device_entry.area_id == area_entry.id
+
+ await hass.config_entries.async_unload(new_entry.entry_id)
+ await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+ cancel_scanner()
+ await hass.async_block_till_done()
diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py
index be4412db4d8..682cff62969 100644
--- a/tests/components/bluetooth/test_diagnostics.py
+++ b/tests/components/bluetooth/test_diagnostics.py
@@ -133,6 +133,20 @@ async def test_diagnostics(
}
},
"manager": {
+ "allocations": {
+ "00:00:00:00:00:01": {
+ "allocated": [],
+ "free": 5,
+ "slots": 5,
+ "source": "00:00:00:00:00:01",
+ },
+ "00:00:00:00:00:02": {
+ "allocated": [],
+ "free": 2,
+ "slots": 2,
+ "source": "00:00:00:00:00:02",
+ },
+ },
"adapters": {
"hci0": {
"address": "00:00:00:00:00:01",
@@ -291,6 +305,14 @@ async def test_diagnostics_macos(
}
},
"manager": {
+ "allocations": {
+ "Core Bluetooth": {
+ "allocated": [],
+ "free": 5,
+ "slots": 5,
+ "source": "Core Bluetooth",
+ },
+ },
"adapters": {
"Core Bluetooth": {
"address": "00:00:00:00:00:00",
@@ -484,6 +506,14 @@ async def test_diagnostics_remote_adapter(
},
"dbus": {},
"manager": {
+ "allocations": {
+ "00:00:00:00:00:01": {
+ "allocated": [],
+ "free": 5,
+ "slots": 5,
+ "source": "00:00:00:00:00:01",
+ },
+ },
"adapters": {
"hci0": {
"address": "00:00:00:00:00:01",
diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py
index ba8792a79a3..2c8c9e70e7f 100644
--- a/tests/components/bluetooth/test_init.py
+++ b/tests/components/bluetooth/test_init.py
@@ -18,6 +18,7 @@ from homeassistant.components.bluetooth import (
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfo,
+ HaBluetoothConnector,
async_process_advertisements,
async_rediscover_address,
async_track_unavailable,
@@ -25,11 +26,16 @@ from homeassistant.components.bluetooth import (
from homeassistant.components.bluetooth.const import (
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
CONF_PASSIVE,
+ CONF_SOURCE,
+ CONF_SOURCE_CONFIG_ENTRY_ID,
+ CONF_SOURCE_DOMAIN,
+ CONF_SOURCE_MODEL,
DOMAIN,
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS,
)
+from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager
from homeassistant.components.bluetooth.match import (
ADDRESS,
CONNECTABLE,
@@ -46,7 +52,9 @@ from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from . import (
+ FakeRemoteScanner,
FakeScanner,
+ MockBleakClient,
_get_manager,
async_setup_with_default_adapter,
async_setup_with_one_adapter,
@@ -3022,6 +3030,23 @@ async def test_scanner_count_connectable(hass: HomeAssistant) -> None:
cancel()
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_scanner_remove(hass: HomeAssistant) -> None:
+ """Test permanently removing a scanner."""
+ scanner = FakeScanner("any", "any")
+ cancel = bluetooth.async_register_scanner(hass, scanner)
+ assert bluetooth.async_scanner_count(hass, connectable=True) == 1
+ device = generate_ble_device("44:44:33:11:23:45", "name")
+ adv = generate_advertisement_data(local_name="name", service_uuids=[])
+ inject_advertisement_with_time_and_source_connectable(
+ hass, device, adv, time.monotonic(), scanner.source, True
+ )
+ cancel()
+ bluetooth.async_remove_scanner(hass, scanner.source)
+ manager: HomeAssistantBluetoothManager = _get_manager()
+ assert not manager.storage.async_get_advertisement_history(scanner.source)
+
+
@pytest.mark.usefixtures("enable_bluetooth")
async def test_scanner_count(hass: HomeAssistant) -> None:
"""Test getting the connectable and non-connectable scanner count."""
@@ -3245,3 +3270,33 @@ async def test_title_updated_if_mac_address(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.title == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)"
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_cleanup_orphened_remote_scanner_config_entry(
+ hass: HomeAssistant,
+) -> None:
+ """Test the remote scanner config entries get cleaned up when orphened."""
+ connector = (
+ HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
+ )
+ scanner = FakeRemoteScanner("esp32", "esp32", connector, True)
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_SOURCE: scanner.source,
+ CONF_SOURCE_DOMAIN: "test",
+ CONF_SOURCE_MODEL: "test",
+ CONF_SOURCE_CONFIG_ENTRY_ID: "no_longer_exists",
+ },
+ unique_id=scanner.source,
+ )
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Orphened remote scanner config entry should be cleaned up
+ assert not hass.config_entries.async_entry_for_domain_unique_id(
+ "bluetooth", scanner.source
+ )
diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py
index 0454df9a4a7..be23a536f49 100644
--- a/tests/components/bluetooth/test_manager.py
+++ b/tests/components/bluetooth/test_manager.py
@@ -1,6 +1,5 @@
"""Tests for the Bluetooth integration manager."""
-from collections.abc import Generator
from datetime import timedelta
import time
from typing import Any
@@ -8,6 +7,7 @@ from unittest.mock import patch
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bluetooth_adapters import AdvertisementHistory
+from freezegun import freeze_time
# pylint: disable-next=no-name-in-module
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
@@ -36,13 +36,17 @@ from homeassistant.components.bluetooth.const import (
SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS,
)
+from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
+from homeassistant.util.dt import utcnow
from homeassistant.util.json import json_loads
from . import (
+ HCI0_SOURCE_ADDRESS,
+ HCI1_SOURCE_ADDRESS,
FakeScanner,
MockBleakClient,
_get_manager,
@@ -63,24 +67,6 @@ from tests.common import (
)
-@pytest.fixture
-def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]:
- """Register an hci0 scanner."""
- hci0_scanner = FakeScanner("hci0", "hci0")
- cancel = bluetooth.async_register_scanner(hass, hci0_scanner)
- yield
- cancel()
-
-
-@pytest.fixture
-def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]:
- """Register an hci1 scanner."""
- hci1_scanner = FakeScanner("hci1", "hci1")
- cancel = bluetooth.async_register_scanner(hass, hci1_scanner)
- yield
- cancel()
-
-
@pytest.mark.usefixtures("enable_bluetooth")
async def test_advertisements_do_not_switch_adapters_for_no_reason(
hass: HomeAssistant,
@@ -98,7 +84,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
- hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
+ hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS
)
assert (
@@ -113,7 +99,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason(
local_name="wohand_signal_99", service_uuids=[]
)
inject_advertisement_with_source(
- hass, switchbot_device_signal_99, switchbot_adv_signal_99, "hci0"
+ hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS
)
assert (
@@ -128,7 +114,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason(
local_name="wohand_good_signal", service_uuids=[]
)
inject_advertisement_with_source(
- hass, switchbot_device_signal_98, switchbot_adv_signal_98, "hci1"
+ hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS
)
# should not switch to hci1
@@ -153,7 +139,10 @@ async def test_switching_adapters_based_on_rssi(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ HCI0_SOURCE_ADDRESS,
)
assert (
@@ -166,7 +155,10 @@ async def test_switching_adapters_based_on_rssi(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
- hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1"
+ hass,
+ switchbot_device_good_signal,
+ switchbot_adv_good_signal,
+ HCI1_SOURCE_ADDRESS,
)
assert (
@@ -175,7 +167,10 @@ async def test_switching_adapters_based_on_rssi(
)
inject_advertisement_with_source(
- hass, switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0"
+ hass,
+ switchbot_device_good_signal,
+ switchbot_adv_poor_signal,
+ HCI0_SOURCE_ADDRESS,
)
assert (
bluetooth.async_ble_device_from_address(hass, address)
@@ -191,7 +186,10 @@ async def test_switching_adapters_based_on_rssi(
)
inject_advertisement_with_source(
- hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0"
+ hass,
+ switchbot_device_similar_signal,
+ switchbot_adv_similar_signal,
+ HCI0_SOURCE_ADDRESS,
)
assert (
bluetooth.async_ble_device_from_address(hass, address)
@@ -214,7 +212,7 @@ async def test_switching_adapters_based_on_zero_rssi(
local_name="wohand_no_rssi", service_uuids=[], rssi=0
)
inject_advertisement_with_source(
- hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0"
+ hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
@@ -227,7 +225,10 @@ async def test_switching_adapters_based_on_zero_rssi(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
- hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1"
+ hass,
+ switchbot_device_good_signal,
+ switchbot_adv_good_signal,
+ HCI1_SOURCE_ADDRESS,
)
assert (
@@ -236,7 +237,7 @@ async def test_switching_adapters_based_on_zero_rssi(
)
inject_advertisement_with_source(
- hass, switchbot_device_good_signal, switchbot_adv_no_rssi, "hci0"
+ hass, switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
bluetooth.async_ble_device_from_address(hass, address)
@@ -252,7 +253,10 @@ async def test_switching_adapters_based_on_zero_rssi(
)
inject_advertisement_with_source(
- hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0"
+ hass,
+ switchbot_device_similar_signal,
+ switchbot_adv_similar_signal,
+ HCI0_SOURCE_ADDRESS,
)
assert (
bluetooth.async_ble_device_from_address(hass, address)
@@ -282,7 +286,7 @@ async def test_switching_adapters_based_on_stale(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
- "hci0",
+ HCI0_SOURCE_ADDRESS,
)
assert (
@@ -301,7 +305,7 @@ async def test_switching_adapters_based_on_stale(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
- "hci1",
+ HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
@@ -349,7 +353,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
- "hci0",
+ HCI0_SOURCE_ADDRESS,
)
assert (
@@ -370,7 +374,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
- "hci1",
+ HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
@@ -384,7 +388,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + 1,
- "hci1",
+ HCI1_SOURCE_ADDRESS,
)
# Should not switch yet since we are not within the
@@ -399,7 +403,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1,
- "hci1",
+ HCI1_SOURCE_ADDRESS,
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
@@ -420,7 +424,9 @@ async def test_restore_history_from_dbus(
ble_device = generate_ble_device(address, "name")
history = {
address: AdvertisementHistory(
- ble_device, generate_advertisement_data(local_name="name"), "hci0"
+ ble_device,
+ generate_advertisement_data(local_name="name"),
+ "hci0",
)
}
@@ -432,6 +438,8 @@ async def test_restore_history_from_dbus(
await hass.async_block_till_done()
assert bluetooth.async_ble_device_from_address(hass, address) is ble_device
+ info = bluetooth.async_last_service_info(hass, address, False)
+ assert info.source == "00:00:00:00:00:01"
@pytest.mark.usefixtures("one_adapter")
@@ -456,7 +464,9 @@ async def test_restore_history_from_dbus_and_remote_adapters(
ble_device = generate_ble_device(address, "name")
history = {
address: AdvertisementHistory(
- ble_device, generate_advertisement_data(local_name="name"), "hci0"
+ ble_device,
+ generate_advertisement_data(local_name="name"),
+ HCI0_SOURCE_ADDRESS,
)
}
@@ -496,7 +506,9 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters(
ble_device = generate_ble_device(address, "name")
history = {
address: AdvertisementHistory(
- ble_device, generate_advertisement_data(local_name="name"), "hci0"
+ ble_device,
+ generate_advertisement_data(local_name="name"),
+ HCI0_SOURCE_ADDRESS,
)
}
@@ -527,7 +539,12 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ now,
+ HCI0_SOURCE_ADDRESS,
+ True,
)
assert (
@@ -623,7 +640,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
- "hci1",
+ HCI1_SOURCE_ADDRESS,
False,
)
@@ -638,7 +655,12 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ now,
+ HCI0_SOURCE_ADDRESS,
+ True,
)
assert (
@@ -678,7 +700,10 @@ async def test_switching_adapters_when_one_goes_away(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
@@ -690,7 +715,10 @@ async def test_switching_adapters_when_one_goes_away(
cancel_hci2()
inject_advertisement_with_source(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ HCI0_SOURCE_ADDRESS,
)
# Now that hci2 is gone, we should prefer the poor signal
@@ -729,7 +757,10 @@ async def test_switching_adapters_when_one_stop_scanning(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
@@ -741,7 +772,10 @@ async def test_switching_adapters_when_one_stop_scanning(
hci2_scanner.scanning = False
inject_advertisement_with_source(
- hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
+ hass,
+ switchbot_device_poor_signal,
+ switchbot_adv_poor_signal,
+ HCI0_SOURCE_ADDRESS,
)
# Now that hci2 has stopped scanning, we should prefer the poor signal
@@ -1660,3 +1694,71 @@ async def test_bluetooth_rediscover_no_match(
cancel()
unsetup_connectable_scanner()
cancel_connectable_scanner()
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_async_register_disappeared_callback(
+ hass: HomeAssistant,
+ register_hci0_scanner: None,
+ register_hci1_scanner: None,
+) -> None:
+ """Test bluetooth async_register_disappeared_callback handles failures."""
+ address = "44:44:33:11:23:12"
+
+ switchbot_device_signal_100 = generate_ble_device(
+ address, "wohand_signal_100", rssi=-100
+ )
+ switchbot_adv_signal_100 = generate_advertisement_data(
+ local_name="wohand_signal_100", service_uuids=[]
+ )
+ inject_advertisement_with_source(
+ hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
+ )
+
+ failed_disappeared: list[str] = []
+
+ def _failing_callback(_address: str) -> None:
+ """Failing callback."""
+ failed_disappeared.append(_address)
+ raise ValueError("This is a test")
+
+ ok_disappeared: list[str] = []
+
+ def _ok_callback(_address: str) -> None:
+ """Ok callback."""
+ ok_disappeared.append(_address)
+
+ manager: HomeAssistantBluetoothManager = _get_manager()
+ cancel1 = manager.async_register_disappeared_callback(_failing_callback)
+ # Make sure the second callback still works if the first one fails and
+ # raises an exception
+ cancel2 = manager.async_register_disappeared_callback(_ok_callback)
+
+ switchbot_adv_signal_100 = generate_advertisement_data(
+ local_name="wohand_signal_100",
+ manufacturer_data={123: b"abc"},
+ service_uuids=[],
+ rssi=-80,
+ )
+ inject_advertisement_with_source(
+ hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
+ )
+
+ future_time = utcnow() + timedelta(seconds=3600)
+ future_monotonic_time = time.monotonic() + 3600
+ with (
+ freeze_time(future_time),
+ patch(
+ "habluetooth.manager.monotonic_time_coarse",
+ return_value=future_monotonic_time,
+ ),
+ ):
+ async_fire_time_changed(hass, future_time)
+
+ assert len(ok_disappeared) == 1
+ assert ok_disappeared[0] == address
+ assert len(failed_disappeared) == 1
+ assert failed_disappeared[0] == address
+
+ cancel1()
+ cancel2()
diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py
index d7a7a8ba08c..e9274965e3c 100644
--- a/tests/components/bluetooth/test_passive_update_processor.py
+++ b/tests/components/bluetooth/test_passive_update_processor.py
@@ -1808,6 +1808,7 @@ async def test_naming(hass: HomeAssistant) -> None:
sensor_entity: PassiveBluetoothProcessorEntity = sensor_entities[0]
sensor_entity.hass = hass
+ sensor_entity.platform = MockEntityPlatform(hass)
assert sensor_entity.available is True
assert sensor_entity.name is UNDEFINED
assert sensor_entity.device_class is SensorDeviceClass.TEMPERATURE
diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py
new file mode 100644
index 00000000000..57199d04078
--- /dev/null
+++ b/tests/components/bluetooth/test_websocket_api.py
@@ -0,0 +1,432 @@
+"""The tests for the bluetooth WebSocket API."""
+
+import asyncio
+from datetime import timedelta
+import time
+from unittest.mock import ANY, patch
+
+from bleak_retry_connector import Allocations
+from freezegun import freeze_time
+import pytest
+
+from homeassistant.components.bluetooth import DOMAIN
+from homeassistant.core import HomeAssistant
+from homeassistant.util.dt import utcnow
+
+from . import (
+ HCI0_SOURCE_ADDRESS,
+ HCI1_SOURCE_ADDRESS,
+ NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
+ FakeScanner,
+ _get_manager,
+ generate_advertisement_data,
+ generate_ble_device,
+ inject_advertisement_with_source,
+)
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+from tests.typing import WebSocketGenerator
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_advertisements(
+ hass: HomeAssistant,
+ register_hci0_scanner: None,
+ register_hci1_scanner: None,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_advertisements."""
+ address = "44:44:33:11:23:12"
+
+ switchbot_device_signal_100 = generate_ble_device(
+ address, "wohand_signal_100", rssi=-100
+ )
+ switchbot_adv_signal_100 = generate_advertisement_data(
+ local_name="wohand_signal_100", service_uuids=[]
+ )
+ inject_advertisement_with_source(
+ hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS
+ )
+
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_advertisements",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "add": [
+ {
+ "address": "44:44:33:11:23:12",
+ "connectable": True,
+ "manufacturer_data": {},
+ "name": "wohand_signal_100",
+ "rssi": -127,
+ "service_data": {},
+ "service_uuids": [],
+ "source": HCI0_SOURCE_ADDRESS,
+ "time": ANY,
+ "tx_power": -127,
+ }
+ ]
+ }
+ adv_time = response["event"]["add"][0]["time"]
+
+ switchbot_adv_signal_100 = generate_advertisement_data(
+ local_name="wohand_signal_100",
+ manufacturer_data={123: b"abc"},
+ service_uuids=[],
+ rssi=-80,
+ )
+ inject_advertisement_with_source(
+ hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI1_SOURCE_ADDRESS
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "add": [
+ {
+ "address": "44:44:33:11:23:12",
+ "connectable": True,
+ "manufacturer_data": {"123": "616263"},
+ "name": "wohand_signal_100",
+ "rssi": -80,
+ "service_data": {},
+ "service_uuids": [],
+ "source": HCI1_SOURCE_ADDRESS,
+ "time": ANY,
+ "tx_power": -127,
+ }
+ ]
+ }
+ new_time = response["event"]["add"][0]["time"]
+ assert new_time > adv_time
+ future_time = utcnow() + timedelta(seconds=3600)
+ future_monotonic_time = time.monotonic() + 3600
+ with (
+ freeze_time(future_time),
+ patch(
+ "habluetooth.manager.monotonic_time_coarse",
+ return_value=future_monotonic_time,
+ ),
+ ):
+ async_fire_time_changed(hass, future_time)
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]}
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_connection_allocations(
+ hass: HomeAssistant,
+ register_hci0_scanner: None,
+ register_hci1_scanner: None,
+ register_non_connectable_scanner: None,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_connection_allocations."""
+ address = "44:44:33:11:23:12"
+
+ switchbot_device_signal_100 = generate_ble_device(
+ address, "wohand_signal_100", rssi=-100
+ )
+ switchbot_adv_signal_100 = generate_advertisement_data(
+ local_name="wohand_signal_100", service_uuids=[]
+ )
+ inject_advertisement_with_source(
+ hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS
+ )
+
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_connection_allocations",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+
+ assert response["event"] == [
+ {
+ "allocated": [],
+ "free": 5,
+ "slots": 5,
+ "source": "00:00:00:00:00:01",
+ },
+ {
+ "allocated": [],
+ "free": 5,
+ "slots": 5,
+ "source": HCI0_SOURCE_ADDRESS,
+ },
+ {
+ "allocated": [],
+ "free": 5,
+ "slots": 5,
+ "source": HCI1_SOURCE_ADDRESS,
+ },
+ {
+ "allocated": [],
+ "free": 0,
+ "slots": 0,
+ "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
+ },
+ ]
+
+ manager = _get_manager()
+ manager.async_on_allocation_changed(
+ Allocations(
+ adapter="hci1", # Will be translated to source
+ slots=5,
+ free=4,
+ allocated=["AA:BB:CC:DD:EE:EE"],
+ )
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == [
+ {
+ "allocated": ["AA:BB:CC:DD:EE:EE"],
+ "free": 4,
+ "slots": 5,
+ "source": "AA:BB:CC:DD:EE:11",
+ },
+ ]
+ manager.async_on_allocation_changed(
+ Allocations(
+ adapter="hci1", # Will be translated to source
+ slots=5,
+ free=5,
+ allocated=[],
+ )
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == [
+ {"allocated": [], "free": 5, "slots": 5, "source": HCI1_SOURCE_ADDRESS}
+ ]
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_connection_allocations_specific_scanner(
+ hass: HomeAssistant,
+ register_non_connectable_scanner: None,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_connection_allocations for a specific source address."""
+ entry = MockConfigEntry(
+ domain=DOMAIN, unique_id=NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS
+ )
+ entry.add_to_hass(hass)
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_connection_allocations",
+ "config_entry_id": entry.entry_id,
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+
+ assert response["event"] == [
+ {
+ "allocated": [],
+ "free": 0,
+ "slots": 0,
+ "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
+ }
+ ]
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_connection_allocations_invalid_config_entry_id(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_connection_allocations for an invalid config entry id."""
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_connection_allocations",
+ "config_entry_id": "non_existent",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "invalid_config_entry_id"
+ assert response["error"]["message"] == "Config entry non_existent not found"
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_connection_allocations_invalid_scanner(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_connection_allocations for an invalid source address."""
+ entry = MockConfigEntry(domain=DOMAIN, unique_id="invalid")
+ entry.add_to_hass(hass)
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_connection_allocations",
+ "config_entry_id": entry.entry_id,
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "invalid_source"
+ assert response["error"]["message"] == "Source invalid not found"
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_scanner_details(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_connection_allocations."""
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_scanner_details",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+
+ assert response["event"] == {
+ "add": [
+ {
+ "adapter": "hci0",
+ "connectable": False,
+ "name": "hci0 (00:00:00:00:00:01)",
+ "source": "00:00:00:00:00:01",
+ }
+ ]
+ }
+
+ manager = _get_manager()
+ hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
+ cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner)
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "add": [
+ {
+ "adapter": "hci3",
+ "connectable": False,
+ "name": "hci3 (AA:BB:CC:DD:EE:33)",
+ "source": "AA:BB:CC:DD:EE:33",
+ }
+ ]
+ }
+ cancel_hci3()
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "remove": [
+ {
+ "adapter": "hci3",
+ "connectable": False,
+ "name": "hci3 (AA:BB:CC:DD:EE:33)",
+ "source": "AA:BB:CC:DD:EE:33",
+ }
+ ]
+ }
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_scanner_details_specific_scanner(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_scanner_details for a specific source address."""
+ entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33")
+ entry.add_to_hass(hass)
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_scanner_details",
+ "config_entry_id": entry.entry_id,
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+ manager = _get_manager()
+ hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
+ cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner)
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "add": [
+ {
+ "adapter": "hci3",
+ "connectable": False,
+ "name": "hci3 (AA:BB:CC:DD:EE:33)",
+ "source": "AA:BB:CC:DD:EE:33",
+ }
+ ]
+ }
+ cancel_hci3()
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "remove": [
+ {
+ "adapter": "hci3",
+ "connectable": False,
+ "name": "hci3 (AA:BB:CC:DD:EE:33)",
+ "source": "AA:BB:CC:DD:EE:33",
+ }
+ ]
+ }
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_scanner_details_invalid_config_entry_id(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_scanner_details for an invalid config entry id."""
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_scanner_details",
+ "config_entry_id": "non_existent",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "invalid_config_entry_id"
+ assert response["error"]["message"] == "Invalid config entry id: non_existent"
diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py
index c437e1d3669..2cd65364604 100644
--- a/tests/components/bmw_connected_drive/__init__.py
+++ b/tests/components/bmw_connected_drive/__init__.py
@@ -53,6 +53,13 @@ REMOTE_SERVICE_EXC_TRANSLATION = (
"Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway"
)
+BIMMER_CONNECTED_LOGIN_PATCH = (
+ "homeassistant.components.bmw_connected_drive.config_flow.MyBMWAuthentication.login"
+)
+BIMMER_CONNECTED_VEHICLE_PATCH = (
+ "homeassistant.components.bmw_connected_drive.coordinator.MyBMWAccount.get_vehicles"
+)
+
async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Mock a fully setup config entry and all components based on fixtures."""
diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py
index 9c124261392..2d4b1390ccc 100644
--- a/tests/components/bmw_connected_drive/test_config_flow.py
+++ b/tests/components/bmw_connected_drive/test_config_flow.py
@@ -15,11 +15,13 @@ from homeassistant.components.bmw_connected_drive.const import (
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
)
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
+ BIMMER_CONNECTED_LOGIN_PATCH,
+ BIMMER_CONNECTED_VEHICLE_PATCH,
FIXTURE_CAPTCHA_INPUT,
FIXTURE_CONFIG_ENTRY,
FIXTURE_GCID,
@@ -40,97 +42,11 @@ def login_sideeffect(self: MyBMWAuthentication):
self.gcid = FIXTURE_GCID
-async def test_show_form(hass: HomeAssistant) -> None:
- """Test that the form is served with no input."""
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
- )
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
-
-
-async def test_authentication_error(hass: HomeAssistant) -> None:
- """Test we show user form on MyBMW authentication error."""
-
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
- side_effect=MyBMWAuthError("Login failed"),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_USER},
- data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
- )
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {"base": "invalid_auth"}
-
-
-async def test_connection_error(hass: HomeAssistant) -> None:
- """Test we show user form on MyBMW API error."""
-
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
- side_effect=RequestError("Connection reset"),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_USER},
- data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
- )
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {"base": "cannot_connect"}
-
-
-async def test_api_error(hass: HomeAssistant) -> None:
- """Test we show user form on general connection error."""
-
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
- side_effect=MyBMWAPIError("400 Bad Request"),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_USER},
- data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
- )
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {"base": "cannot_connect"}
-
-
-@pytest.mark.usefixtures("bmw_fixture")
-async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None:
- """Test the external flow with captcha failing once and succeeding the second time."""
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_USER},
- data=deepcopy(FIXTURE_USER_INPUT),
- )
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "captcha"
-
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_CAPTCHA_TOKEN: " "}
- )
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {"base": "missing_captcha"}
-
-
async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
"""Test registering an integration and finishing flow works."""
with (
patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
+ BIMMER_CONNECTED_LOGIN_PATCH,
side_effect=login_sideeffect,
autospec=True,
),
@@ -155,15 +71,125 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
assert result["data"] == FIXTURE_COMPLETE_ENTRY
+ assert (
+ result["result"].unique_id
+ == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}"
+ )
assert len(mock_setup_entry.mock_calls) == 1
+@pytest.mark.parametrize(
+ ("side_effect", "error"),
+ [
+ (MyBMWAuthError("Login failed"), "invalid_auth"),
+ (RequestError("Connection reset"), "cannot_connect"),
+ (MyBMWAPIError("400 Bad Request"), "cannot_connect"),
+ ],
+)
+async def test_error_display_with_successful_login(
+ hass: HomeAssistant, side_effect: Exception, error: str
+) -> None:
+ """Test we show user form on MyBMW authentication error and are still able to succeed."""
+
+ with patch(
+ BIMMER_CONNECTED_LOGIN_PATCH,
+ side_effect=side_effect,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": error}
+
+ with (
+ patch(
+ BIMMER_CONNECTED_LOGIN_PATCH,
+ side_effect=login_sideeffect,
+ autospec=True,
+ ),
+ patch(
+ "homeassistant.components.bmw_connected_drive.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ deepcopy(FIXTURE_USER_INPUT),
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "captcha"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], FIXTURE_CAPTCHA_INPUT
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
+ assert result["data"] == FIXTURE_COMPLETE_ENTRY
+ assert (
+ result["result"].unique_id
+ == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}"
+ )
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_unique_id_existing(hass: HomeAssistant) -> None:
+ """Test registering an integration and when the unique id already exists."""
+
+ mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
+ mock_config_entry.add_to_hass(hass)
+
+ with (
+ patch(
+ BIMMER_CONNECTED_LOGIN_PATCH,
+ side_effect=login_sideeffect,
+ autospec=True,
+ ),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=deepcopy(FIXTURE_USER_INPUT),
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None:
+ """Test the external flow with captcha failing once and succeeding the second time."""
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=deepcopy(FIXTURE_USER_INPUT),
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "captcha"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_CAPTCHA_TOKEN: " "}
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "missing_captcha"}
+
+
async def test_options_flow_implementation(hass: HomeAssistant) -> None:
"""Test config flow options."""
with (
patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ BIMMER_CONNECTED_VEHICLE_PATCH,
return_value=[],
),
patch(
@@ -200,7 +226,7 @@ async def test_reauth(hass: HomeAssistant) -> None:
"""Test the reauth form."""
with (
patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
+ BIMMER_CONNECTED_LOGIN_PATCH,
side_effect=login_sideeffect,
autospec=True,
),
@@ -249,7 +275,7 @@ async def test_reauth(hass: HomeAssistant) -> None:
async def test_reconfigure(hass: HomeAssistant) -> None:
"""Test the reconfiguration form."""
with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
+ BIMMER_CONNECTED_LOGIN_PATCH,
side_effect=login_sideeffect,
autospec=True,
):
diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py
index beb3d74d572..2e317ec1334 100644
--- a/tests/components/bmw_connected_drive/test_coordinator.py
+++ b/tests/components/bmw_connected_drive/test_coordinator.py
@@ -1,7 +1,6 @@
-"""Test BMW coordinator."""
+"""Test BMW coordinator for general availability/unavailability of entities and raising issues."""
from copy import deepcopy
-from datetime import timedelta
from unittest.mock import patch
from bimmer_connected.models import (
@@ -13,27 +12,56 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN
+from homeassistant.components.bmw_connected_drive.const import (
+ CONF_REFRESH_TOKEN,
+ SCAN_INTERVALS,
+)
from homeassistant.const import CONF_REGION
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
-from homeassistant.helpers.update_coordinator import UpdateFailed
-from . import FIXTURE_CONFIG_ENTRY
+from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY
from tests.common import MockConfigEntry, async_fire_time_changed
+FIXTURE_ENTITY_STATES = {
+ "binary_sensor.m340i_xdrive_door_lock_state": "off",
+ "lock.m340i_xdrive_lock": "locked",
+ "lock.i3_rex_lock": "unlocked",
+ "number.ix_xdrive50_target_soc": "80",
+ "sensor.ix_xdrive50_rear_left_tire_pressure": "2.61",
+ "sensor.ix_xdrive50_rear_right_tire_pressure": "2.69",
+}
+FIXTURE_DEFAULT_REGION = FIXTURE_CONFIG_ENTRY["data"][CONF_REGION]
+
@pytest.mark.usefixtures("bmw_fixture")
-async def test_update_success(hass: HomeAssistant) -> None:
- """Test the reauth form."""
- config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
+async def test_config_entry_update(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test if the coordinator updates the refresh token in config entry."""
+ config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY)
+ config_entry_fixure["data"][CONF_REFRESH_TOKEN] = "old_token"
+ config_entry = MockConfigEntry(**config_entry_fixure)
config_entry.add_to_hass(hass)
+ assert (
+ hass.config_entries.async_get_entry(config_entry.entry_id).data[
+ CONF_REFRESH_TOKEN
+ ]
+ == "old_token"
+ )
+
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- assert config_entry.runtime_data.last_update_success is True
+ assert (
+ hass.config_entries.async_get_entry(config_entry.entry_id).data[
+ CONF_REFRESH_TOKEN
+ ]
+ == "another_token_string"
+ )
@pytest.mark.usefixtures("bmw_fixture")
@@ -41,125 +69,176 @@ async def test_update_failed(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
- """Test the reauth form."""
+ """Test a failing API call."""
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- coordinator = config_entry.runtime_data
-
- assert coordinator.last_update_success is True
-
- freezer.tick(timedelta(minutes=5, seconds=1))
+ # Test if entities show data correctly
+ for entity_id, state in FIXTURE_ENTITY_STATES.items():
+ assert hass.states.get(entity_id).state == state
+ # On API error, entities should be unavailable
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ BIMMER_CONNECTED_VEHICLE_PATCH,
side_effect=MyBMWAPIError("Test error"),
):
async_fire_time_changed(hass)
await hass.async_block_till_done()
- assert coordinator.last_update_success is False
- assert isinstance(coordinator.last_exception, UpdateFailed) is True
+ for entity_id in FIXTURE_ENTITY_STATES:
+ assert hass.states.get(entity_id).state == "unavailable"
+
+ # And should recover on next update
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ for entity_id, state in FIXTURE_ENTITY_STATES.items():
+ assert hass.states.get(entity_id).state == state
@pytest.mark.usefixtures("bmw_fixture")
-async def test_update_reauth(
+async def test_auth_failed_as_update_failed(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
+ issue_registry: ir.IssueRegistry,
) -> None:
- """Test the reauth form."""
+ """Test a single auth failure not initializing reauth flow."""
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- coordinator = config_entry.runtime_data
+ # Test if entities show data correctly
+ for entity_id, state in FIXTURE_ENTITY_STATES.items():
+ assert hass.states.get(entity_id).state == state
- assert coordinator.last_update_success is True
-
- freezer.tick(timedelta(minutes=5, seconds=1))
+ # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ BIMMER_CONNECTED_VEHICLE_PATCH,
side_effect=MyBMWAuthError("Test error"),
):
async_fire_time_changed(hass)
await hass.async_block_till_done()
- assert coordinator.last_update_success is False
- assert isinstance(coordinator.last_exception, UpdateFailed) is True
+ for entity_id in FIXTURE_ENTITY_STATES:
+ assert hass.states.get(entity_id).state == "unavailable"
- freezer.tick(timedelta(minutes=5, seconds=1))
- with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
- side_effect=MyBMWAuthError("Test error"),
- ):
- async_fire_time_changed(hass)
- await hass.async_block_till_done()
+ # And should recover on next update
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
- assert coordinator.last_update_success is False
- assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True
+ for entity_id, state in FIXTURE_ENTITY_STATES.items():
+ assert hass.states.get(entity_id).state == state
+
+ # Verify that no issues are raised and no reauth flow is initialized
+ assert len(issue_registry.issues) == 0
+ assert len(hass.config_entries.flow.async_progress_by_handler(BMW_DOMAIN)) == 0
@pytest.mark.usefixtures("bmw_fixture")
-async def test_init_reauth(
+async def test_auth_failed_init_reauth(
hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
issue_registry: ir.IssueRegistry,
) -> None:
- """Test the reauth form."""
+ """Test a two subsequent auth failures initializing reauth flow."""
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Test if entities show data correctly
+ for entity_id, state in FIXTURE_ENTITY_STATES.items():
+ assert hass.states.get(entity_id).state == state
assert len(issue_registry.issues) == 0
+ # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ BIMMER_CONNECTED_VEHICLE_PATCH,
side_effect=MyBMWAuthError("Test error"),
):
- await hass.config_entries.async_setup(config_entry.entry_id)
+ async_fire_time_changed(hass)
await hass.async_block_till_done()
+ for entity_id in FIXTURE_ENTITY_STATES:
+ assert hass.states.get(entity_id).state == "unavailable"
+ assert len(issue_registry.issues) == 0
+
+ # On second failure, we should initialize reauth flow
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
+ with patch(
+ BIMMER_CONNECTED_VEHICLE_PATCH,
+ side_effect=MyBMWAuthError("Test error"),
+ ):
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ for entity_id in FIXTURE_ENTITY_STATES:
+ assert hass.states.get(entity_id).state == "unavailable"
+ assert len(issue_registry.issues) == 1
+
reauth_issue = issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN,
f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}",
)
assert reauth_issue.active is True
+ # Check if reauth flow is initialized correctly
+ flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"])
+ assert flow["handler"] == BMW_DOMAIN
+ assert flow["context"]["source"] == "reauth"
+ assert flow["context"]["unique_id"] == config_entry.unique_id
+
@pytest.mark.usefixtures("bmw_fixture")
async def test_captcha_reauth(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
+ issue_registry: ir.IssueRegistry,
) -> None:
- """Test the reauth form."""
- TEST_REGION = "north_america"
-
- config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY)
- config_entry_fixure["data"][CONF_REGION] = TEST_REGION
- config_entry = MockConfigEntry(**config_entry_fixure)
+ """Test a CaptchaError initializing reauth flow."""
+ config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- coordinator = config_entry.runtime_data
+ # Test if entities show data correctly
+ for entity_id, state in FIXTURE_ENTITY_STATES.items():
+ assert hass.states.get(entity_id).state == state
- assert coordinator.last_update_success is True
-
- freezer.tick(timedelta(minutes=10, seconds=1))
+ # If library decides a captcha is needed, we should initialize reauth flow
+ freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
- side_effect=MyBMWCaptchaMissingError(
- "Missing hCaptcha token for North America login"
- ),
+ BIMMER_CONNECTED_VEHICLE_PATCH,
+ side_effect=MyBMWCaptchaMissingError("Missing hCaptcha token"),
):
async_fire_time_changed(hass)
await hass.async_block_till_done()
- assert coordinator.last_update_success is False
- assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True
- assert coordinator.last_exception.translation_key == "missing_captcha"
+ for entity_id in FIXTURE_ENTITY_STATES:
+ assert hass.states.get(entity_id).state == "unavailable"
+ assert len(issue_registry.issues) == 1
+
+ reauth_issue = issue_registry.async_get_issue(
+ HOMEASSISTANT_DOMAIN,
+ f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}",
+ )
+ assert reauth_issue.active is True
+
+ # Check if reauth flow is initialized correctly
+ flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"])
+ assert flow["handler"] == BMW_DOMAIN
+ assert flow["context"]["source"] == "reauth"
+ assert flow["context"]["unique_id"] == config_entry.unique_id
diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py
index 8507cacc376..d0624825cb5 100644
--- a/tests/components/bmw_connected_drive/test_init.py
+++ b/tests/components/bmw_connected_drive/test_init.py
@@ -14,7 +14,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from . import FIXTURE_CONFIG_ENTRY
+from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY
from tests.common import MockConfigEntry
@@ -156,7 +156,7 @@ async def test_migrate_unique_ids(
assert entity.unique_id == old_unique_id
with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ BIMMER_CONNECTED_VEHICLE_PATCH,
return_value=[],
):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -212,7 +212,7 @@ async def test_dont_migrate_unique_ids(
assert entity.unique_id == old_unique_id
with patch(
- "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ BIMMER_CONNECTED_VEHICLE_PATCH,
return_value=[],
):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py
index 53c39f572f2..878edefac27 100644
--- a/tests/components/bmw_connected_drive/test_select.py
+++ b/tests/components/bmw_connected_drive/test_select.py
@@ -138,13 +138,6 @@ async def test_service_call_invalid_input(
HomeAssistantError,
REMOTE_SERVICE_EXC_TRANSLATION,
),
- (
- ServiceValidationError(
- "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit"
- ),
- ServiceValidationError,
- "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit",
- ),
],
)
async def test_service_call_fail(
diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py
index d61ed4844a1..73aece4af6b 100644
--- a/tests/components/bond/test_config_flow.py
+++ b/tests/components/bond/test_config_flow.py
@@ -10,12 +10,12 @@ from unittest.mock import MagicMock, Mock, patch
from aiohttp import ClientConnectionError, ClientResponseError
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.bond.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .common import (
patch_bond_bridge,
@@ -219,7 +219,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -260,7 +260,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -302,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -349,7 +349,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -393,7 +393,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -437,7 +437,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.2"),
ip_addresses=[ip_address("127.0.0.2")],
hostname="mock_hostname",
@@ -475,7 +475,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.2"),
ip_addresses=[ip_address("127.0.0.2")],
hostname="mock_hostname",
@@ -522,7 +522,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) ->
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.2"),
ip_addresses=[ip_address("127.0.0.2")],
hostname="mock_hostname",
@@ -561,7 +561,7 @@ async def test_zeroconf_already_configured_no_reload_same_host(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.3"),
ip_addresses=[ip_address("127.0.0.3")],
hostname="mock_hostname",
@@ -583,7 +583,7 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None:
await _help_test_form_unexpected_error(
hass,
source=config_entries.SOURCE_ZEROCONF,
- initial_input=zeroconf.ZeroconfServiceInfo(
+ initial_input=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py
index 63f7169b026..06fd5b9102c 100644
--- a/tests/components/bosch_shc/test_config_flow.py
+++ b/tests/components/bosch_shc/test_config_flow.py
@@ -13,11 +13,11 @@ from boschshcpy.information import SHCInformation
import pytest
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.bosch_shc.config_flow import write_tls_asset
from homeassistant.components.bosch_shc.const import CONF_SHC_CERT, CONF_SHC_KEY, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
@@ -25,7 +25,7 @@ MOCK_SETTINGS = {
"name": "Test name",
"device": {"mac": "test-mac", "hostname": "test-host"},
}
-DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
+DISCOVERY_INFO = ZeroconfServiceInfo(
ip_address=ip_address("1.1.1.1"),
ip_addresses=[ip_address("1.1.1.1")],
hostname="shc012345.local.",
@@ -615,7 +615,7 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant) -> None:
"""Test we filter out non-bosch_shc devices."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("1.1.1.1"),
ip_addresses=[ip_address("1.1.1.1")],
hostname="mock_hostname",
diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py
index 7a4f93f7f16..497e88053f5 100644
--- a/tests/components/braviatv/test_config_flow.py
+++ b/tests/components/braviatv/test_config_flow.py
@@ -10,7 +10,6 @@ from pybravia import (
)
import pytest
-from homeassistant.components import ssdp
from homeassistant.components.braviatv.const import (
CONF_NICKNAME,
CONF_USE_PSK,
@@ -22,6 +21,12 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import instance_id
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from tests.common import MockConfigEntry
@@ -46,14 +51,14 @@ BRAVIA_SOURCES = [
{"title": "AV/Component", "uri": "extInput:component?port=1"},
]
-BRAVIA_SSDP = ssdp.SsdpServiceInfo(
+BRAVIA_SSDP = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://bravia-host:52323/dmr.xml",
upnp={
- ssdp.ATTR_UPNP_UDN: "uuid:1234",
- ssdp.ATTR_UPNP_FRIENDLY_NAME: "Living TV",
- ssdp.ATTR_UPNP_MODEL_NAME: "KE-55XH9096",
+ ATTR_UPNP_UDN: "uuid:1234",
+ ATTR_UPNP_FRIENDLY_NAME: "Living TV",
+ ATTR_UPNP_MODEL_NAME: "KE-55XH9096",
"X_ScalarWebAPI_DeviceInfo": {
"X_ScalarWebAPI_ServiceList": {
"X_ScalarWebAPI_ServiceType": [
@@ -68,14 +73,14 @@ BRAVIA_SSDP = ssdp.SsdpServiceInfo(
},
)
-FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo(
+FAKE_BRAVIA_SSDP = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://soundbar-host:52323/dmr.xml",
upnp={
- ssdp.ATTR_UPNP_UDN: "uuid:1234",
- ssdp.ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device",
- ssdp.ATTR_UPNP_MODEL_NAME: "HT-S700RF",
+ ATTR_UPNP_UDN: "uuid:1234",
+ ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device",
+ ATTR_UPNP_MODEL_NAME: "HT-S700RF",
"X_ScalarWebAPI_DeviceInfo": {
"X_ScalarWebAPI_ServiceList": {
"X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"],
diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py
index 62aa38d4e92..2b2e9257097 100644
--- a/tests/components/bring/conftest.py
+++ b/tests/components/bring/conftest.py
@@ -1,17 +1,21 @@
"""Common fixtures for the Bring! tests."""
from collections.abc import Generator
-from typing import cast
from unittest.mock import AsyncMock, patch
import uuid
-from bring_api.types import BringAuthResponse
+from bring_api.types import (
+ BringAuthResponse,
+ BringItemsResponse,
+ BringListResponse,
+ BringUserSettingsResponse,
+)
import pytest
-from homeassistant.components.bring import DOMAIN
+from homeassistant.components.bring.const import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
-from tests.common import MockConfigEntry, load_json_object_fixture
+from tests.common import MockConfigEntry, load_fixture
EMAIL = "test-email"
PASSWORD = "test-password"
@@ -43,11 +47,18 @@ def mock_bring_client() -> Generator[AsyncMock]:
):
client = mock_client.return_value
client.uuid = UUID
- client.login.return_value = cast(BringAuthResponse, {"name": "Bring"})
- client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN)
- client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN)
- client.get_all_user_settings.return_value = load_json_object_fixture(
- "usersettings.json", DOMAIN
+ client.mail = EMAIL
+ client.login.return_value = BringAuthResponse.from_json(
+ load_fixture("login.json", DOMAIN)
+ )
+ client.load_lists.return_value = BringListResponse.from_json(
+ load_fixture("lists.json", DOMAIN)
+ )
+ client.get_list.return_value = BringItemsResponse.from_json(
+ load_fixture("items.json", DOMAIN)
+ )
+ client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json(
+ load_fixture("usersettings.json", DOMAIN)
)
yield client
diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json
index e0b9006167b..eecdbaac8c7 100644
--- a/tests/components/bring/fixtures/items.json
+++ b/tests/components/bring/fixtures/items.json
@@ -1,44 +1,46 @@
{
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
"status": "REGISTERED",
- "purchase": [
- {
- "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
- "itemId": "Paprika",
- "specification": "Rot",
- "attributes": [
- {
- "type": "PURCHASE_CONDITIONS",
- "content": {
- "urgent": true,
- "convenient": true,
- "discounted": true
+ "items": {
+ "purchase": [
+ {
+ "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
+ "itemId": "Paprika",
+ "specification": "Rot",
+ "attributes": [
+ {
+ "type": "PURCHASE_CONDITIONS",
+ "content": {
+ "urgent": true,
+ "convenient": true,
+ "discounted": true
+ }
}
- }
- ]
- },
- {
- "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
- "itemId": "Pouletbrüstli",
- "specification": "Bio",
- "attributes": [
- {
- "type": "PURCHASE_CONDITIONS",
- "content": {
- "urgent": true,
- "convenient": true,
- "discounted": true
+ ]
+ },
+ {
+ "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
+ "itemId": "Pouletbrüstli",
+ "specification": "Bio",
+ "attributes": [
+ {
+ "type": "PURCHASE_CONDITIONS",
+ "content": {
+ "urgent": true,
+ "convenient": true,
+ "discounted": true
+ }
}
- }
- ]
- }
- ],
- "recently": [
- {
- "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
- "itemId": "Ananas",
- "specification": "",
- "attributes": []
- }
- ]
+ ]
+ }
+ ],
+ "recently": [
+ {
+ "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
+ "itemId": "Ananas",
+ "specification": "",
+ "attributes": []
+ }
+ ]
+ }
}
diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json
index 82ef623e439..be3671c359a 100644
--- a/tests/components/bring/fixtures/items_invitation.json
+++ b/tests/components/bring/fixtures/items_invitation.json
@@ -1,44 +1,46 @@
{
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
"status": "INVITATION",
- "purchase": [
- {
- "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
- "itemId": "Paprika",
- "specification": "Rot",
- "attributes": [
- {
- "type": "PURCHASE_CONDITIONS",
- "content": {
- "urgent": true,
- "convenient": true,
- "discounted": true
+ "items": {
+ "purchase": [
+ {
+ "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
+ "itemId": "Paprika",
+ "specification": "Rot",
+ "attributes": [
+ {
+ "type": "PURCHASE_CONDITIONS",
+ "content": {
+ "urgent": true,
+ "convenient": true,
+ "discounted": true
+ }
}
- }
- ]
- },
- {
- "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
- "itemId": "Pouletbrüstli",
- "specification": "Bio",
- "attributes": [
- {
- "type": "PURCHASE_CONDITIONS",
- "content": {
- "urgent": true,
- "convenient": true,
- "discounted": true
+ ]
+ },
+ {
+ "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
+ "itemId": "Pouletbrüstli",
+ "specification": "Bio",
+ "attributes": [
+ {
+ "type": "PURCHASE_CONDITIONS",
+ "content": {
+ "urgent": true,
+ "convenient": true,
+ "discounted": true
+ }
}
- }
- ]
- }
- ],
- "recently": [
- {
- "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
- "itemId": "Ananas",
- "specification": "",
- "attributes": []
- }
- ]
+ ]
+ }
+ ],
+ "recently": [
+ {
+ "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
+ "itemId": "Ananas",
+ "specification": "",
+ "attributes": []
+ }
+ ]
+ }
}
diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json
index 9ac999729d3..5e381d27ca8 100644
--- a/tests/components/bring/fixtures/items_shared.json
+++ b/tests/components/bring/fixtures/items_shared.json
@@ -1,44 +1,46 @@
{
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
"status": "SHARED",
- "purchase": [
- {
- "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
- "itemId": "Paprika",
- "specification": "Rot",
- "attributes": [
- {
- "type": "PURCHASE_CONDITIONS",
- "content": {
- "urgent": true,
- "convenient": true,
- "discounted": true
+ "items": {
+ "purchase": [
+ {
+ "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
+ "itemId": "Paprika",
+ "specification": "Rot",
+ "attributes": [
+ {
+ "type": "PURCHASE_CONDITIONS",
+ "content": {
+ "urgent": true,
+ "convenient": true,
+ "discounted": true
+ }
}
- }
- ]
- },
- {
- "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
- "itemId": "Pouletbrüstli",
- "specification": "Bio",
- "attributes": [
- {
- "type": "PURCHASE_CONDITIONS",
- "content": {
- "urgent": true,
- "convenient": true,
- "discounted": true
+ ]
+ },
+ {
+ "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
+ "itemId": "Pouletbrüstli",
+ "specification": "Bio",
+ "attributes": [
+ {
+ "type": "PURCHASE_CONDITIONS",
+ "content": {
+ "urgent": true,
+ "convenient": true,
+ "discounted": true
+ }
}
- }
- ]
- }
- ],
- "recently": [
- {
- "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
- "itemId": "Ananas",
- "specification": "",
- "attributes": []
- }
- ]
+ ]
+ }
+ ],
+ "recently": [
+ {
+ "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
+ "itemId": "Ananas",
+ "specification": "",
+ "attributes": []
+ }
+ ]
+ }
}
diff --git a/tests/components/bring/fixtures/login.json b/tests/components/bring/fixtures/login.json
new file mode 100644
index 00000000000..62616471734
--- /dev/null
+++ b/tests/components/bring/fixtures/login.json
@@ -0,0 +1,12 @@
+{
+ "uuid": "4d717571-174a-4bc1-ab24-929c7227ca43",
+ "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d",
+ "email": "test-email",
+ "name": "Bring",
+ "photoPath": "",
+ "bringListUUID": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
+ "access_token": "ACCESS_TOKEN",
+ "refresh_token": "REFRESH_TOKEN",
+ "token_type": "Bearer",
+ "expires_in": 604799
+}
diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr
index 6d830a12133..5955ded832a 100644
--- a/tests/components/bring/snapshots/test_diagnostics.ambr
+++ b/tests/components/bring/snapshots/test_diagnostics.ambr
@@ -2,100 +2,112 @@
# name: test_diagnostics
dict({
'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({
- 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd',
- 'name': 'Baumarkt',
- 'purchase': list([
- dict({
- 'attributes': list([
+ 'content': dict({
+ 'items': dict({
+ 'purchase': list([
dict({
- 'content': dict({
- 'convenient': True,
- 'discounted': True,
- 'urgent': True,
- }),
- 'type': 'PURCHASE_CONDITIONS',
+ 'attributes': list([
+ dict({
+ 'content': dict({
+ 'convenient': True,
+ 'discounted': True,
+ 'urgent': True,
+ }),
+ 'type': 'PURCHASE_CONDITIONS',
+ }),
+ ]),
+ 'itemId': 'Paprika',
+ 'specification': 'Rot',
+ 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de',
+ }),
+ dict({
+ 'attributes': list([
+ dict({
+ 'content': dict({
+ 'convenient': True,
+ 'discounted': True,
+ 'urgent': True,
+ }),
+ 'type': 'PURCHASE_CONDITIONS',
+ }),
+ ]),
+ 'itemId': 'Pouletbrüstli',
+ 'specification': 'Bio',
+ 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e',
}),
]),
- 'itemId': 'Paprika',
- 'specification': 'Rot',
- 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de',
- }),
- dict({
- 'attributes': list([
+ 'recently': list([
dict({
- 'content': dict({
- 'convenient': True,
- 'discounted': True,
- 'urgent': True,
- }),
- 'type': 'PURCHASE_CONDITIONS',
+ 'attributes': list([
+ ]),
+ 'itemId': 'Ananas',
+ 'specification': '',
+ 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954',
}),
]),
- 'itemId': 'Pouletbrüstli',
- 'specification': 'Bio',
- 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e',
}),
- ]),
- 'recently': list([
- dict({
- 'attributes': list([
- ]),
- 'itemId': 'Ananas',
- 'specification': '',
- 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954',
- }),
- ]),
- 'status': 'REGISTERED',
- 'theme': 'ch.publisheria.bring.theme.home',
- 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f',
+ 'status': 'REGISTERED',
+ 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f',
+ }),
+ 'lst': dict({
+ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd',
+ 'name': 'Baumarkt',
+ 'theme': 'ch.publisheria.bring.theme.home',
+ }),
}),
'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({
- 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5',
- 'name': 'Einkauf',
- 'purchase': list([
- dict({
- 'attributes': list([
+ 'content': dict({
+ 'items': dict({
+ 'purchase': list([
dict({
- 'content': dict({
- 'convenient': True,
- 'discounted': True,
- 'urgent': True,
- }),
- 'type': 'PURCHASE_CONDITIONS',
+ 'attributes': list([
+ dict({
+ 'content': dict({
+ 'convenient': True,
+ 'discounted': True,
+ 'urgent': True,
+ }),
+ 'type': 'PURCHASE_CONDITIONS',
+ }),
+ ]),
+ 'itemId': 'Paprika',
+ 'specification': 'Rot',
+ 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de',
+ }),
+ dict({
+ 'attributes': list([
+ dict({
+ 'content': dict({
+ 'convenient': True,
+ 'discounted': True,
+ 'urgent': True,
+ }),
+ 'type': 'PURCHASE_CONDITIONS',
+ }),
+ ]),
+ 'itemId': 'Pouletbrüstli',
+ 'specification': 'Bio',
+ 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e',
}),
]),
- 'itemId': 'Paprika',
- 'specification': 'Rot',
- 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de',
- }),
- dict({
- 'attributes': list([
+ 'recently': list([
dict({
- 'content': dict({
- 'convenient': True,
- 'discounted': True,
- 'urgent': True,
- }),
- 'type': 'PURCHASE_CONDITIONS',
+ 'attributes': list([
+ ]),
+ 'itemId': 'Ananas',
+ 'specification': '',
+ 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954',
}),
]),
- 'itemId': 'Pouletbrüstli',
- 'specification': 'Bio',
- 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e',
}),
- ]),
- 'recently': list([
- dict({
- 'attributes': list([
- ]),
- 'itemId': 'Ananas',
- 'specification': '',
- 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954',
- }),
- ]),
- 'status': 'REGISTERED',
- 'theme': 'ch.publisheria.bring.theme.home',
- 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f',
+ 'status': 'REGISTERED',
+ 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f',
+ }),
+ 'lst': dict({
+ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5',
+ 'name': 'Einkauf',
+ 'theme': 'ch.publisheria.bring.theme.home',
+ }),
}),
})
# ---
diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py
index 5ee66999ea4..8c215e024d5 100644
--- a/tests/components/bring/test_init.py
+++ b/tests/components/bring/test_init.py
@@ -1,21 +1,22 @@
"""Unit tests for the bring integration."""
+from datetime import timedelta
from unittest.mock import AsyncMock
+from bring_api import BringAuthException, BringParseException, BringRequestException
+from freezegun.api import FrozenDateTimeFactory
import pytest
-from homeassistant.components.bring import (
- BringAuthException,
- BringParseException,
- BringRequestException,
- async_setup_entry,
-)
+from homeassistant.components.bring import async_setup_entry
from homeassistant.components.bring.const import DOMAIN
-from homeassistant.config_entries import ConfigEntryState
+from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
-from tests.common import MockConfigEntry
+from .conftest import UUID
+
+from tests.common import MockConfigEntry, async_fire_time_changed
async def setup_integration(
@@ -115,13 +116,20 @@ async def test_config_entry_not_ready(
@pytest.mark.parametrize(
- "exception", [None, BringAuthException, BringRequestException, BringParseException]
+ ("exception", "state"),
+ [
+ (None, ConfigEntryState.LOADED),
+ (BringAuthException, ConfigEntryState.SETUP_ERROR),
+ (BringRequestException, ConfigEntryState.SETUP_RETRY),
+ (BringParseException, ConfigEntryState.SETUP_RETRY),
+ ],
)
async def test_config_entry_not_ready_auth_error(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
exception: Exception | None,
+ state: ConfigEntryState,
) -> None:
"""Test config entry not ready from authentication error."""
@@ -132,4 +140,33 @@ async def test_config_entry_not_ready_auth_error(
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
- assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY
+ assert bring_config_entry.state is state
+
+
+@pytest.mark.usefixtures("mock_bring_client")
+async def test_coordinator_skips_deactivated(
+ hass: HomeAssistant,
+ bring_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+ mock_bring_client: AsyncMock,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test the coordinator skips fetching lists for deactivated lists."""
+ await setup_integration(hass, bring_config_entry)
+
+ assert bring_config_entry.state is ConfigEntryState.LOADED
+
+ assert mock_bring_client.get_list.await_count == 2
+
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, f"{UUID}_b4776778-7f6c-496e-951b-92a35d3db0dd")}
+ )
+ device_registry.async_update_device(device.id, disabled_by=ConfigEntryDisabler.USER)
+
+ mock_bring_client.get_list.reset_mock()
+
+ freezer.tick(timedelta(seconds=90))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert mock_bring_client.get_list.await_count == 1
diff --git a/tests/components/bring/test_notification.py b/tests/components/bring/test_notification.py
index b1fa28335ad..711598d3f4b 100644
--- a/tests/components/bring/test_notification.py
+++ b/tests/components/bring/test_notification.py
@@ -65,7 +65,7 @@ async def test_send_notification_exception(
mock_bring_client.notify.side_effect = BringRequestException
with pytest.raises(
HomeAssistantError,
- match="Failed to send push notification for bring due to a connection error, try again later",
+ match="Failed to send push notification for Bring! due to a connection error, try again later",
):
await hass.services.async_call(
DOMAIN,
@@ -94,7 +94,7 @@ async def test_send_notification_service_validation_error(
with pytest.raises(
HomeAssistantError,
match=re.escape(
- "Failed to perform action bring.send_message. 'URGENT_MESSAGE' requires a value @ data['item']. Got None"
+ "This action requires field item, please enter a valid value for item"
),
):
await hass.services.async_call(
diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py
index 974818ccedf..442fea5a247 100644
--- a/tests/components/bring/test_sensor.py
+++ b/tests/components/bring/test_sensor.py
@@ -3,6 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
+from bring_api import BringItemsResponse
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -12,7 +13,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform
+from tests.common import MockConfigEntry, load_fixture, snapshot_platform
@pytest.fixture(autouse=True)
@@ -62,10 +63,9 @@ async def test_list_access_states(
) -> None:
"""Snapshot test states of list access sensor."""
- mock_bring_client.get_list.return_value = load_json_object_fixture(
- f"{fixture}.json", DOMAIN
+ mock_bring_client.get_list.return_value = BringItemsResponse.from_json(
+ load_fixture(f"{fixture}.json", DOMAIN)
)
-
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py
index 0d9ed0c5345..3060f31c134 100644
--- a/tests/components/bring/test_util.py
+++ b/tests/components/bring/test_util.py
@@ -1,15 +1,13 @@
"""Test for utility functions of the Bring! integration."""
-from typing import cast
-
-from bring_api import BringUserSettingsResponse
+from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse
import pytest
-from homeassistant.components.bring import DOMAIN
+from homeassistant.components.bring.const import DOMAIN
from homeassistant.components.bring.coordinator import BringData
from homeassistant.components.bring.util import list_language, sum_attributes
-from tests.common import load_json_object_fixture
+from tests.common import load_fixture
@pytest.mark.parametrize(
@@ -17,7 +15,7 @@ from tests.common import load_json_object_fixture
[
("e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "de-DE"),
("b4776778-7f6c-496e-951b-92a35d3db0dd", "en-US"),
- ("00000000-0000-0000-0000-00000000", None),
+ ("00000000-0000-0000-0000-000000000000", None),
],
)
def test_list_language(list_uuid: str, expected: str | None) -> None:
@@ -25,10 +23,7 @@ def test_list_language(list_uuid: str, expected: str | None) -> None:
result = list_language(
list_uuid,
- cast(
- BringUserSettingsResponse,
- load_json_object_fixture("usersettings.json", DOMAIN),
- ),
+ BringUserSettingsResponse.from_json(load_fixture("usersettings.json", DOMAIN)),
)
assert result == expected
@@ -44,12 +39,11 @@ def test_list_language(list_uuid: str, expected: str | None) -> None:
)
def test_sum_attributes(attribute: str, expected: int) -> None:
"""Test function sum_attributes."""
+ items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN))
+ lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN))
result = sum_attributes(
- cast(
- BringData,
- load_json_object_fixture("items.json", DOMAIN),
- ),
+ BringData(lst.lists[0], items),
attribute,
)
diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py
index f31cb380631..14e41bbff19 100644
--- a/tests/components/broadlink/test_config_flow.py
+++ b/tests/components/broadlink/test_config_flow.py
@@ -8,10 +8,10 @@ import broadlink.exceptions as blke
import pytest
from homeassistant import config_entries
-from homeassistant.components import dhcp
from homeassistant.components.broadlink.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import get_device
@@ -828,7 +828,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip="1.2.3.4",
macaddress=device.mac,
@@ -862,7 +862,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip="1.2.3.4",
macaddress="34ea34b43b5a",
@@ -881,7 +881,7 @@ async def test_dhcp_unreachable(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip="1.2.3.4",
macaddress="34ea34b43b5a",
@@ -900,7 +900,7 @@ async def test_dhcp_connect_unknown_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip="1.2.3.4",
macaddress="34ea34b43b5a",
@@ -922,7 +922,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip=device.host,
macaddress=device.mac,
@@ -946,7 +946,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip="1.2.3.4",
macaddress="34ea34b43b5a",
@@ -971,7 +971,7 @@ async def test_dhcp_updates_host(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="broadlink",
ip="4.5.6.7",
macaddress="34ea34b43b5a",
diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py
index 2d4eb8e0e0b..7e3ae4efcab 100644
--- a/tests/components/broadlink/test_switch.py
+++ b/tests/components/broadlink/test_switch.py
@@ -92,7 +92,7 @@ async def test_slots_switch_setup_works(
for slot, switch in enumerate(switches):
assert (
hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME]
- == f"{device.name} S{slot+1}"
+ == f"{device.name} S{slot + 1}"
)
assert hass.states.get(switch.entity_id).state == STATE_OFF
assert mock_setup.api.auth.call_count == 1
diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py
index 929e2f083e9..945f5549bbe 100644
--- a/tests/components/brother/test_config_flow.py
+++ b/tests/components/brother/test_config_flow.py
@@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, patch
from brother import SnmpError, UnsupportedModelError
import pytest
-from homeassistant.components import zeroconf
from homeassistant.components.brother.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import init_integration
@@ -121,7 +121,7 @@ async def test_zeroconf_exception(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
@@ -145,7 +145,7 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
@@ -171,7 +171,7 @@ async def test_zeroconf_device_exists_abort(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
@@ -200,7 +200,7 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
@@ -224,7 +224,7 @@ async def test_zeroconf_confirm_create_entry(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py
index 7ee12c5fa1a..41d566fc375 100644
--- a/tests/components/bsblan/test_climate.py
+++ b/tests/components/bsblan/test_climate.py
@@ -22,7 +22,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py
index c95671a1a6b..ba2af40f319 100644
--- a/tests/components/bsblan/test_sensor.py
+++ b/tests/components/bsblan/test_sensor.py
@@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py
index ed920774aa5..173498b14ff 100644
--- a/tests/components/bsblan/test_water_heater.py
+++ b/tests/components/bsblan/test_water_heater.py
@@ -20,7 +20,7 @@ from homeassistant.components.water_heater import (
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
diff --git a/tests/components/bthome/test_config_flow.py b/tests/components/bthome/test_config_flow.py
index faf2f1c9ef5..5aea3a3cc9b 100644
--- a/tests/components/bthome/test_config_flow.py
+++ b/tests/components/bthome/test_config_flow.py
@@ -213,6 +213,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None:
assert result2["result"].unique_id == "54:48:E6:8F:80:A5"
+async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None:
+ """Test setup from service info cache replaces an ignored entry."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="54:48:E6:8F:80:A5",
+ data={},
+ source=config_entries.SOURCE_IGNORE,
+ )
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.bthome.config_flow.async_discovered_service_info",
+ return_value=[PRST_SERVICE_INFO],
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ with patch("homeassistant.components.bthome.async_setup_entry", return_value=True):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={"address": "54:48:E6:8F:80:A5"},
+ )
+ assert result2["type"] is FlowResultType.CREATE_ENTRY
+ assert result2["title"] == "b-parasite 80A5"
+ assert result2["data"] == {}
+ assert result2["result"].unique_id == "54:48:E6:8F:80:A5"
+
+
async def test_async_step_user_with_found_devices_encryption(
hass: HomeAssistant,
) -> None:
diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py
index 36b102b933a..2d712f408c2 100644
--- a/tests/components/calendar/test_init.py
+++ b/tests/components/calendar/test_init.py
@@ -16,7 +16,7 @@ from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .conftest import MockCalendarEntity, MockConfigEntry
diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py
index dfe4622e82e..b0d7944041d 100644
--- a/tests/components/calendar/test_trigger.py
+++ b/tests/components/calendar/test_trigger.py
@@ -25,7 +25,7 @@ from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .conftest import MockCalendarEntity
diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py
index 8d01db6e015..fc184ae2ef5 100644
--- a/tests/components/cambridge_audio/test_config_flow.py
+++ b/tests/components/cambridge_audio/test_config_flow.py
@@ -6,11 +6,11 @@ from unittest.mock import AsyncMock
from aiostreammagic import StreamMagicError
from homeassistant.components.cambridge_audio.const import DOMAIN
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
index 32520fcad23..7fd469fa51a 100644
--- a/tests/components/camera/test_init.py
+++ b/tests/components/camera/test_init.py
@@ -41,6 +41,7 @@ from homeassistant.util import dt as dt_util
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg
from tests.common import (
+ MockEntityPlatform,
async_fire_time_changed,
help_test_all,
import_and_test_deprecated_constant_enum,
@@ -300,13 +301,24 @@ async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_camera")
-async def test_snapshot_service_os_error(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+@pytest.mark.parametrize(
+ ("target", "side_effect"),
+ [
+ ("homeassistant.components.camera.os.makedirs", OSError),
+ (
+ "homeassistant.components.demo.camera.DemoCamera.async_camera_image",
+ TimeoutError,
+ ),
+ ],
+)
+async def test_snapshot_service_error(
+ hass: HomeAssistant, target: str, side_effect: Exception
) -> None:
- """Test snapshot service with os error."""
+ """Test snapshot service with error."""
with (
patch.object(hass.config, "is_allowed_path", return_value=True),
- patch("homeassistant.components.camera.os.makedirs", side_effect=OSError),
+ patch(target, side_effect=side_effect),
+ pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
camera.DOMAIN,
@@ -318,8 +330,6 @@ async def test_snapshot_service_os_error(
blocking=True,
)
- assert "Can't write image to file:" in caplog.text
-
@pytest.mark.usefixtures("mock_camera", "mock_stream")
async def test_websocket_stream_no_source(
@@ -826,7 +836,9 @@ def test_deprecated_state_constants(
import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10")
-def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
+def test_deprecated_supported_features_ints(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
"""Test deprecated supported features ints."""
class MockCamera(camera.Camera):
@@ -836,6 +848,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) ->
return 1
entity = MockCamera()
+ entity.hass = hass
+ entity.platform = MockEntityPlatform(hass)
assert entity.supported_features_compat is camera.CameraEntityFeature(1)
assert "MockCamera" in caplog.text
assert "is using deprecated supported features values" in caplog.text
diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py
index 892d7541354..811978eead5 100644
--- a/tests/components/clicksend_tts/test_notify.py
+++ b/tests/components/clicksend_tts/test_notify.py
@@ -9,7 +9,7 @@ import pytest
import requests_mock
from homeassistant.components import notify
-import homeassistant.components.clicksend_tts.notify as cs_tts
+from homeassistant.components.clicksend_tts import notify as cs_tts
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py
index d17f3a1747d..00ab2f8d278 100644
--- a/tests/components/climate/test_intent.py
+++ b/tests/components/climate/test_intent.py
@@ -1,13 +1,16 @@
"""Test climate intents."""
from collections.abc import Generator
+from typing import Any
import pytest
from homeassistant.components import conversation
from homeassistant.components.climate import (
+ ATTR_TEMPERATURE,
DOMAIN,
ClimateEntity,
+ ClimateEntityFeature,
HVACMode,
intent as climate_intent,
)
@@ -15,7 +18,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
+from homeassistant.helpers import (
+ area_registry as ar,
+ entity_registry as er,
+ floor_registry as fr,
+ intent,
+)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.setup import async_setup_component
@@ -107,6 +115,20 @@ class MockClimateEntity(ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_mode = HVACMode.OFF
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
+ _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set the thermostat temperature."""
+ value = kwargs[ATTR_TEMPERATURE]
+ self._attr_target_temperature = value
+
+
+class MockClimateEntityNoSetTemperature(ClimateEntity):
+ """Mock Climate device to use in tests."""
+
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_hvac_mode = HVACMode.OFF
+ _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
async def test_get_temperature(
@@ -436,3 +458,231 @@ async def test_not_exposed(
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
+
+
+async def test_set_temperature(
+ hass: HomeAssistant,
+ area_registry: ar.AreaRegistry,
+ entity_registry: er.EntityRegistry,
+ floor_registry: fr.FloorRegistry,
+) -> None:
+ """Test HassClimateSetTemperature intent."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ await climate_intent.async_setup_intents(hass)
+
+ climate_1 = MockClimateEntity()
+ climate_1._attr_name = "Climate 1"
+ climate_1._attr_unique_id = "1234"
+ climate_1._attr_current_temperature = 10.0
+ climate_1._attr_target_temperature = 10.0
+ entity_registry.async_get_or_create(
+ DOMAIN, "test", "1234", suggested_object_id="climate_1"
+ )
+
+ climate_2 = MockClimateEntity()
+ climate_2._attr_name = "Climate 2"
+ climate_2._attr_unique_id = "5678"
+ climate_2._attr_current_temperature = 22.0
+ climate_2._attr_target_temperature = 22.0
+ entity_registry.async_get_or_create(
+ DOMAIN, "test", "5678", suggested_object_id="climate_2"
+ )
+
+ await create_mock_platform(hass, [climate_1, climate_2])
+
+ # Add climate entities to different areas:
+ # climate_1 => living room
+ # climate_2 => bedroom
+ # nothing in office
+ living_room_area = area_registry.async_create(name="Living Room")
+ bedroom_area = area_registry.async_create(name="Bedroom")
+ office_area = area_registry.async_create(name="Office")
+
+ entity_registry.async_update_entity(
+ climate_1.entity_id, area_id=living_room_area.id
+ )
+ entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id)
+
+ # Put areas on different floors:
+ # first floor => living room and office
+ # upstairs => bedroom
+ floor_registry = fr.async_get(hass)
+ first_floor = floor_registry.async_create("First floor")
+ living_room_area = area_registry.async_update(
+ living_room_area.id, floor_id=first_floor.floor_id
+ )
+ office_area = area_registry.async_update(
+ office_area.id, floor_id=first_floor.floor_id
+ )
+
+ second_floor = floor_registry.async_create("Second floor")
+ bedroom_area = area_registry.async_update(
+ bedroom_area.id, floor_id=second_floor.floor_id
+ )
+
+ # Cannot target multiple climate devices
+ with pytest.raises(intent.MatchFailedError) as err:
+ await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"temperature": {"value": 20}},
+ assistant=conversation.DOMAIN,
+ )
+ assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS
+
+ # Select by area explicitly (climate_2)
+ response = await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"area": {"value": bedroom_area.name}, "temperature": {"value": 20.1}},
+ assistant=conversation.DOMAIN,
+ )
+ assert response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert len(response.matched_states) == 1
+ assert response.matched_states[0].entity_id == climate_2.entity_id
+ state = hass.states.get(climate_2.entity_id)
+ assert state.attributes[ATTR_TEMPERATURE] == 20.1
+
+ # Select by area implicitly (climate_2)
+ response = await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {
+ "preferred_area_id": {"value": bedroom_area.id},
+ "temperature": {"value": 20.2},
+ },
+ assistant=conversation.DOMAIN,
+ )
+ assert response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert response.matched_states
+ assert response.matched_states[0].entity_id == climate_2.entity_id
+ state = hass.states.get(climate_2.entity_id)
+ assert state.attributes[ATTR_TEMPERATURE] == 20.2
+
+ # Select by floor explicitly (climate_2)
+ response = await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"floor": {"value": second_floor.name}, "temperature": {"value": 20.3}},
+ assistant=conversation.DOMAIN,
+ )
+ assert response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert response.matched_states
+ assert response.matched_states[0].entity_id == climate_2.entity_id
+ state = hass.states.get(climate_2.entity_id)
+ assert state.attributes[ATTR_TEMPERATURE] == 20.3
+
+ # Select by floor implicitly (climate_2)
+ response = await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {
+ "preferred_floor_id": {"value": second_floor.floor_id},
+ "temperature": {"value": 20.4},
+ },
+ assistant=conversation.DOMAIN,
+ )
+ assert response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert response.matched_states
+ assert response.matched_states[0].entity_id == climate_2.entity_id
+ state = hass.states.get(climate_2.entity_id)
+ assert state.attributes[ATTR_TEMPERATURE] == 20.4
+
+ # Select by name (climate_2)
+ response = await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"name": {"value": "Climate 2"}, "temperature": {"value": 20.5}},
+ assistant=conversation.DOMAIN,
+ )
+ assert response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert len(response.matched_states) == 1
+ assert response.matched_states[0].entity_id == climate_2.entity_id
+ state = hass.states.get(climate_2.entity_id)
+ assert state.attributes[ATTR_TEMPERATURE] == 20.5
+
+ # Check area with no climate entities (explicit)
+ with pytest.raises(intent.MatchFailedError) as error:
+ response = await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"area": {"value": office_area.name}, "temperature": {"value": 20.6}},
+ assistant=conversation.DOMAIN,
+ )
+
+ # Exception should contain details of what we tried to match
+ assert isinstance(error.value, intent.MatchFailedError)
+ assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
+ constraints = error.value.constraints
+ assert constraints.name is None
+ assert constraints.area_name == office_area.name
+ assert constraints.domains and (set(constraints.domains) == {DOMAIN})
+ assert constraints.device_classes is None
+
+ # Implicit area with no climate entities will fail with multiple targets
+ with pytest.raises(intent.MatchFailedError) as err:
+ await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {
+ "preferred_area_id": {"value": office_area.id},
+ "temperature": {"value": 20.7},
+ },
+ assistant=conversation.DOMAIN,
+ )
+ assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS
+
+
+async def test_set_temperature_no_entities(
+ hass: HomeAssistant,
+) -> None:
+ """Test HassClimateSetTemperature intent with no climate entities."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ await climate_intent.async_setup_intents(hass)
+
+ await create_mock_platform(hass, [])
+
+ with pytest.raises(intent.MatchFailedError) as err:
+ await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"temperature": {"value": 20}},
+ assistant=conversation.DOMAIN,
+ )
+ assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
+
+
+async def test_set_temperature_not_supported(hass: HomeAssistant) -> None:
+ """Test HassClimateSetTemperature intent when climate entity doesn't support required feature."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ await climate_intent.async_setup_intents(hass)
+
+ climate_1 = MockClimateEntityNoSetTemperature()
+ climate_1._attr_name = "Climate 1"
+ climate_1._attr_unique_id = "1234"
+ climate_1._attr_current_temperature = 10.0
+ climate_1._attr_target_temperature = 10.0
+
+ await create_mock_platform(hass, [climate_1])
+
+ with pytest.raises(intent.MatchFailedError) as error:
+ await intent.async_handle(
+ hass,
+ "test",
+ climate_intent.INTENT_SET_TEMPERATURE,
+ {"temperature": {"value": 20.0}},
+ assistant=conversation.DOMAIN,
+ )
+
+ # Exception should contain details of what we tried to match
+ assert isinstance(error.value, intent.MatchFailedError)
+ assert error.value.result.no_match_reason == intent.MatchFailedReason.FEATURE
diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr
new file mode 100644
index 00000000000..9b2f2e0eb33
--- /dev/null
+++ b/tests/components/cloud/snapshots/test_http_api.ambr
@@ -0,0 +1,49 @@
+# serializer version: 1
+# name: test_download_support_package
+ '''
+ ## System Information
+
+ version | core-2025.2.0
+ --- | ---
+ installation_type | Home Assistant Core
+ dev | False
+ hassio | False
+ docker | False
+ user | hass
+ virtualenv | False
+ python_version | 3.13.1
+ os_name | Linux
+ os_version | 6.12.9
+ arch | x86_64
+ timezone | US/Pacific
+ config_dir | config
+
+ mock_no_info_integration
+
+ No information available
+
+
+ cloud
+
+ logged_in | True
+ --- | ---
+ subscription_expiration | 2025-01-17T11:19:31+00:00
+ relayer_connected | True
+ relayer_region | xx-earth-616
+ remote_enabled | True
+ remote_connected | False
+ alexa_enabled | True
+ google_enabled | False
+ cloud_ice_servers_enabled | True
+ remote_server | us-west-1
+ certificate_status | CertificateStatus.READY
+ instance_id | 12345678901234567890
+ can_reach_cert_server | Exception: Unexpected exception
+ can_reach_cloud_auth | Failed: unreachable
+ can_reach_cloud | ok
+
+
+
+
+ '''
+# ---
diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py
index 3b4868b56ac..ef7a99453f0 100644
--- a/tests/components/cloud/test_alexa_config.py
+++ b/tests/components/cloud/test_alexa_config.py
@@ -179,7 +179,7 @@ async def test_alexa_config_invalidate_token(
assert await async_setup_component(hass, "homeassistant", {})
aioclient_mock.post(
- "https://example/access_token",
+ "https://example/alexa/access_token",
json={
"access_token": "mock-token",
"event_endpoint": "http://example.com/alexa_endpoint",
@@ -192,7 +192,7 @@ async def test_alexa_config_invalidate_token(
"mock-user-id",
cloud_prefs,
Mock(
- alexa_server="example",
+ servicehandlers_server="example",
auth=Mock(async_check_token=AsyncMock()),
websession=async_get_clientsession(hass),
),
@@ -239,7 +239,7 @@ async def test_alexa_config_fail_refresh_token(
)
aioclient_mock.post(
- "https://example/access_token",
+ "https://example/alexa/access_token",
json={
"access_token": "mock-token",
"event_endpoint": "http://example.com/alexa_endpoint",
@@ -256,7 +256,7 @@ async def test_alexa_config_fail_refresh_token(
"mock-user-id",
cloud_prefs,
Mock(
- alexa_server="example",
+ servicehandlers_server="example",
auth=Mock(async_check_token=AsyncMock()),
websession=async_get_clientsession(hass),
),
@@ -286,7 +286,7 @@ async def test_alexa_config_fail_refresh_token(
conf.async_invalidate_access_token()
aioclient_mock.clear_requests()
aioclient_mock.post(
- "https://example/access_token",
+ "https://example/alexa/access_token",
json={"reason": reject_reason},
status=400,
)
@@ -312,7 +312,7 @@ async def test_alexa_config_fail_refresh_token(
# State reporting should now be re-enabled for Alexa
aioclient_mock.clear_requests()
aioclient_mock.post(
- "https://example/access_token",
+ "https://example/alexa/access_token",
json={
"access_token": "mock-token",
"event_endpoint": "http://example.com/alexa_endpoint",
diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py
index fc8c7f27e56..5b2b8751311 100644
--- a/tests/components/cloud/test_backup.py
+++ b/tests/components/cloud/test_backup.py
@@ -146,7 +146,10 @@ async def test_agents_info(
assert response["success"]
assert response["result"] == {
- "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}],
+ "agents": [
+ {"agent_id": "backup.local", "name": "local"},
+ {"agent_id": "cloud.cloud", "name": "cloud"},
+ ],
}
@@ -167,16 +170,15 @@ async def test_agents_list_backups(
assert response["result"]["backups"] == [
{
"addons": [],
+ "agents": {"cloud.cloud": {"protected": False, "size": 34519040}},
"backup_id": "23e64aec",
"date": "2024-11-22T11:48:48.727189+01:00",
"database_included": True,
+ "extra_metadata": {},
"folders": [],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0.dev0",
"name": "Core 2024.12.0.dev0",
- "protected": False,
- "size": 34519040,
- "agent_ids": ["cloud.cloud"],
"failed_agent_ids": [],
"with_automatic_settings": None,
}
@@ -204,6 +206,10 @@ async def test_agents_list_backups_fail_cloud(
"backups": [],
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
+ "last_non_idle_event": None,
+ "next_automatic_backup": None,
+ "next_automatic_backup_additional": False,
+ "state": "idle",
}
@@ -214,16 +220,15 @@ async def test_agents_list_backups_fail_cloud(
"23e64aec",
{
"addons": [],
+ "agents": {"cloud.cloud": {"protected": False, "size": 34519040}},
"backup_id": "23e64aec",
"date": "2024-11-22T11:48:48.727189+01:00",
"database_included": True,
+ "extra_metadata": {},
"folders": [],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0.dev0",
"name": "Core 2024.12.0.dev0",
- "protected": False,
- "size": 34519040,
- "agent_ids": ["cloud.cloud"],
"failed_agent_ids": [],
"with_automatic_settings": None,
},
diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py
index b152309b24a..cb456be5036 100644
--- a/tests/components/cloud/test_google_config.py
+++ b/tests/components/cloud/test_google_config.py
@@ -32,7 +32,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
-from tests.common import async_fire_time_changed
+from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
@@ -264,6 +264,7 @@ async def test_google_entity_registry_sync(
@pytest.mark.usefixtures("mock_cloud_login")
async def test_google_device_registry_sync(
hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
cloud_prefs: CloudPreferences,
) -> None:
@@ -275,8 +276,14 @@ async def test_google_device_registry_sync(
# Enable exposing new entities to Google
expose_new(hass, True)
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
entity_entry = entity_registry.async_get_or_create(
- "light", "hue", "1234", device_id="1234"
+ "light", "hue", "1234", device_id=device_entry.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id="ABCD"
@@ -294,7 +301,7 @@ async def test_google_device_registry_sync(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
{
"action": "update",
- "device_id": "1234",
+ "device_id": device_entry.id,
"changes": ["manufacturer"],
},
)
@@ -308,7 +315,7 @@ async def test_google_device_registry_sync(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
{
"action": "update",
- "device_id": "1234",
+ "device_id": device_entry.id,
"changes": ["area_id"],
},
)
@@ -324,7 +331,7 @@ async def test_google_device_registry_sync(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
{
"action": "update",
- "device_id": "1234",
+ "device_id": device_entry.id,
"changes": ["area_id"],
},
)
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index d915f158af0..e4a526ceadd 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -1,10 +1,11 @@
"""Tests for the HTTP API for the cloud component."""
+from collections.abc import Callable, Coroutine
from copy import deepcopy
from http import HTTPStatus
import json
from typing import Any
-from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
import aiohttp
from hass_nabucasa import thingtalk
@@ -15,9 +16,12 @@ from hass_nabucasa.auth import (
UnknownError,
)
from hass_nabucasa.const import STATE_CONNECTED
+from hass_nabucasa.remote import CertificateStatus
from hass_nabucasa.voice import TTS_VOICES
import pytest
+from syrupy.assertion import SnapshotAssertion
+from homeassistant.components import system_health
from homeassistant.components.alexa import errors as alexa_errors
# pylint: disable-next=hass-component-root-import
@@ -30,8 +34,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
from homeassistant.util.location import LocationInfo
+from tests.common import mock_platform
from tests.components.google_assistant import MockConfig
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -112,8 +118,8 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None:
"cognito_client_id": "cognito_client_id",
"user_pool_id": "user_pool_id",
"region": "region",
- "alexa_server": "alexa-api.nabucasa.com",
"relayer_server": "relayer",
+ "acme_server": "cert-server",
"accounts_server": "api-test.hass.io",
"google_actions": {"filter": {"include_domains": "light"}},
"alexa": {
@@ -1861,3 +1867,96 @@ async def test_logout_view_dispatch_event(
assert async_dispatcher_send_mock.call_count == 1
assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event"
assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"}
+
+
+async def test_download_support_package(
+ hass: HomeAssistant,
+ cloud: MagicMock,
+ set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test downloading a support package file."""
+ aioclient_mock.get("https://cloud.bla.com/status", text="")
+ aioclient_mock.get(
+ "https://cert-server/directory", exc=Exception("Unexpected exception")
+ )
+ aioclient_mock.get(
+ "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
+ exc=aiohttp.ClientError,
+ )
+
+ def async_register_mock_platform(
+ hass: HomeAssistant, register: system_health.SystemHealthRegistration
+ ) -> None:
+ async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
+ return {}
+
+ register.async_register_info(mock_empty_info, "/config/mock_integration")
+
+ mock_platform(
+ hass,
+ "mock_no_info_integration.system_health",
+ MagicMock(async_register=async_register_mock_platform),
+ )
+ hass.config.components.add("mock_no_info_integration")
+
+ assert await async_setup_component(hass, "system_health", {})
+
+ with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
+ hexmock.return_value = "12345678901234567890"
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ "user_pool_id": "AAAA",
+ "region": "us-east-1",
+ "acme_server": "cert-server",
+ "relayer_server": "cloud.bla.com",
+ },
+ },
+ )
+ await hass.async_block_till_done()
+
+ await cloud.login("test-user", "test-pass")
+
+ cloud.remote.snitun_server = "us-west-1"
+ cloud.remote.certificate_status = CertificateStatus.READY
+ cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
+
+ await cloud.client.async_system_message({"region": "xx-earth-616"})
+ await set_cloud_prefs(
+ {
+ "alexa_enabled": True,
+ "google_enabled": False,
+ "remote_enabled": True,
+ "cloud_ice_servers_enabled": True,
+ }
+ )
+
+ cloud_client = await hass_client()
+ with (
+ patch.object(hass.config, "config_dir", new="config"),
+ patch(
+ "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info",
+ return_value={
+ "installation_type": "Home Assistant Core",
+ "version": "2025.2.0",
+ "dev": False,
+ "hassio": False,
+ "virtualenv": False,
+ "python_version": "3.13.1",
+ "docker": False,
+ "arch": "x86_64",
+ "timezone": "US/Pacific",
+ "os_name": "Linux",
+ "os_version": "6.12.9",
+ "user": "hass",
+ },
+ ),
+ ):
+ req = await cloud_client.get("/api/cloud/support_package")
+ assert req.status == HTTPStatus.OK
+ assert await req.text() == snapshot
diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py
index ad123cded84..9a6d4abfc93 100644
--- a/tests/components/cloud/test_init.py
+++ b/tests/components/cloud/test_init.py
@@ -45,7 +45,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None:
"relayer_server": "test-relayer-server",
"accounts_server": "test-acounts-server",
"cloudhook_server": "test-cloudhook-server",
- "alexa_server": "test-alexa-server",
"acme_server": "test-acme-server",
"remotestate_server": "test-remotestate-server",
},
@@ -62,7 +61,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None:
assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket"
assert cl.accounts_server == "test-acounts-server"
assert cl.cloudhook_server == "test-cloudhook-server"
- assert cl.alexa_server == "test-alexa-server"
assert cl.acme_server == "test-acme-server"
assert cl.remotestate_server == "test-remotestate-server"
diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py
index d165a129dbe..d131d211e2f 100644
--- a/tests/components/cloud/test_repairs.py
+++ b/tests/components/cloud/test_repairs.py
@@ -12,7 +12,7 @@ from homeassistant.components.cloud.repairs import (
)
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.issue_registry as ir
+from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py
index d629607e503..15a6c5740ff 100644
--- a/tests/components/cloudflare/test_init.py
+++ b/tests/components/cloudflare/test_init.py
@@ -15,7 +15,7 @@ from homeassistant.components.cloudflare.const import (
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.location import LocationInfo
from . import ENTRY_CONFIG, init_integration
diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py
index 23ba5e7808c..3f920b7dee2 100644
--- a/tests/components/color_extractor/test_service.py
+++ b/tests/components/color_extractor/test_service.py
@@ -25,7 +25,7 @@ from homeassistant.components.light import (
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-import homeassistant.util.color as color_util
+from homeassistant.util import color as color_util
from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py
index 426968eccc5..a6e384fdd6b 100644
--- a/tests/components/command_line/test_cover.py
+++ b/tests/components/command_line/test_cover.py
@@ -32,7 +32,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import mock_asyncio_subprocess_run
diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py
index 3fbd0e0f898..16a783d4f59 100644
--- a/tests/components/command_line/test_init.py
+++ b/tests/components/command_line/test_init.py
@@ -11,7 +11,7 @@ from homeassistant import config as hass_config
from homeassistant.components.command_line.const import DOMAIN
from homeassistant.const import SERVICE_RELOAD, STATE_ON, STATE_OPEN
from homeassistant.core import HomeAssistant
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed, get_fixture_path
diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py
index d62410fa792..6b34cf0fa77 100644
--- a/tests/components/command_line/test_switch.py
+++ b/tests/components/command_line/test_switch.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import mock_asyncio_subprocess_run
diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py
index 03a8272e586..81c696bc6a7 100644
--- a/tests/components/config/test_area_registry.py
+++ b/tests/components/config/test_area_registry.py
@@ -7,6 +7,13 @@ import pytest
from pytest_unordered import unordered
from homeassistant.components.config import area_registry
+from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
+ ATTR_UNIT_OF_MEASUREMENT,
+ PERCENTAGE,
+ UnitOfTemperature,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar
from homeassistant.util.dt import utcnow
@@ -24,10 +31,32 @@ async def client_fixture(
return await hass_ws_client(hass)
+@pytest.fixture
+async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None:
+ """Mock temperature and humidity sensors."""
+ hass.states.async_set(
+ "sensor.mock_temperature",
+ "20",
+ {
+ ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
+ ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
+ },
+ )
+ hass.states.async_set(
+ "sensor.mock_humidity",
+ "50",
+ {
+ ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
+ },
+ )
+
+
async def test_list_areas(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
freezer: FrozenDateTimeFactory,
+ mock_temperature_humidity_entity: None,
) -> None:
"""Test list entries."""
created_area1 = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00")
@@ -39,10 +68,12 @@ async def test_list_areas(
area2 = area_registry.async_create(
"mock 2",
aliases={"alias_1", "alias_2"},
- icon="mdi:garage",
- picture="/image/example.png",
floor_id="first_floor",
+ humidity_entity_id="sensor.mock_humidity",
+ icon="mdi:garage",
labels={"label_1", "label_2"},
+ picture="/image/example.png",
+ temperature_entity_id="sensor.mock_temperature",
)
await client.send_json_auto_id({"type": "config/area_registry/list"})
@@ -52,24 +83,28 @@ async def test_list_areas(
{
"aliases": [],
"area_id": area1.id,
+ "created_at": created_area1.timestamp(),
"floor_id": None,
+ "humidity_entity_id": None,
"icon": None,
"labels": [],
+ "modified_at": created_area1.timestamp(),
"name": "mock 1",
"picture": None,
- "created_at": created_area1.timestamp(),
- "modified_at": created_area1.timestamp(),
+ "temperature_entity_id": None,
},
{
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": area2.id,
+ "created_at": created_area2.timestamp(),
"floor_id": "first_floor",
+ "humidity_entity_id": "sensor.mock_humidity",
"icon": "mdi:garage",
"labels": unordered(["label_1", "label_2"]),
+ "modified_at": created_area2.timestamp(),
"name": "mock 2",
"picture": "/image/example.png",
- "created_at": created_area2.timestamp(),
- "modified_at": created_area2.timestamp(),
+ "temperature_entity_id": "sensor.mock_temperature",
},
]
@@ -78,6 +113,7 @@ async def test_create_area(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
freezer: FrozenDateTimeFactory,
+ mock_temperature_humidity_entity: None,
) -> None:
"""Test create entry."""
# Create area with only mandatory parameters
@@ -97,6 +133,8 @@ async def test_create_area(
"picture": None,
"created_at": utcnow().timestamp(),
"modified_at": utcnow().timestamp(),
+ "temperature_entity_id": None,
+ "humidity_entity_id": None,
}
assert len(area_registry.areas) == 1
@@ -109,12 +147,15 @@ async def test_create_area(
"labels": ["label_1", "label_2"],
"name": "mock 2",
"picture": "/image/example.png",
+ "temperature_entity_id": "sensor.mock_temperature",
+ "humidity_entity_id": "sensor.mock_humidity",
"type": "config/area_registry/create",
}
)
msg = await client.receive_json()
+ assert msg["success"]
assert msg["result"] == {
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": ANY,
@@ -125,6 +166,8 @@ async def test_create_area(
"picture": "/image/example.png",
"created_at": utcnow().timestamp(),
"modified_at": utcnow().timestamp(),
+ "temperature_entity_id": "sensor.mock_temperature",
+ "humidity_entity_id": "sensor.mock_humidity",
}
assert len(area_registry.areas) == 2
@@ -185,6 +228,7 @@ async def test_update_area(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
freezer: FrozenDateTimeFactory,
+ mock_temperature_humidity_entity: None,
) -> None:
"""Test update entry."""
created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00")
@@ -195,14 +239,16 @@ async def test_update_area(
await client.send_json_auto_id(
{
+ "type": "config/area_registry/update",
"aliases": ["alias_1", "alias_2"],
"area_id": area.id,
"floor_id": "first_floor",
+ "humidity_entity_id": "sensor.mock_humidity",
"icon": "mdi:garage",
"labels": ["label_1", "label_2"],
"name": "mock 2",
"picture": "/image/example.png",
- "type": "config/area_registry/update",
+ "temperature_entity_id": "sensor.mock_temperature",
}
)
@@ -212,10 +258,12 @@ async def test_update_area(
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": area.id,
"floor_id": "first_floor",
+ "humidity_entity_id": "sensor.mock_humidity",
"icon": "mdi:garage",
"labels": unordered(["label_1", "label_2"]),
"name": "mock 2",
"picture": "/image/example.png",
+ "temperature_entity_id": "sensor.mock_temperature",
"created_at": created_at.timestamp(),
"modified_at": modified_at.timestamp(),
}
@@ -226,13 +274,15 @@ async def test_update_area(
await client.send_json_auto_id(
{
+ "type": "config/area_registry/update",
"aliases": ["alias_1", "alias_1"],
"area_id": area.id,
"floor_id": None,
+ "humidity_entity_id": None,
"icon": None,
"labels": [],
"picture": None,
- "type": "config/area_registry/update",
+ "temperature_entity_id": None,
}
)
@@ -246,6 +296,8 @@ async def test_update_area(
"labels": [],
"name": "mock 2",
"picture": None,
+ "temperature_entity_id": None,
+ "humidity_entity_id": None,
"created_at": created_at.timestamp(),
"modified_at": modified_at.timestamp(),
}
diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py
index 40a9c85a8d3..b20b0fb5699 100644
--- a/tests/components/config/test_automation.py
+++ b/tests/components/config/test_automation.py
@@ -13,7 +13,7 @@ from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
from tests.typing import ClientSessionGenerator
@@ -223,7 +223,7 @@ async def test_update_automation_config_with_blueprint_substitution_error(
with patch(
"homeassistant.components.blueprint.models.BlueprintInputs.async_substitute",
- side_effect=yaml.UndefinedSubstitution("blah"),
+ side_effect=yaml_util.UndefinedSubstitution("blah"),
):
resp = await client.post(
"/api/config/automation/config/moon",
diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py
index ee000c5ada2..f5241f65200 100644
--- a/tests/components/config/test_config_entries.py
+++ b/tests/components/config/test_config_entries.py
@@ -3,6 +3,7 @@
from collections import OrderedDict
from collections.abc import Generator
from http import HTTPStatus
+from typing import Any
from unittest.mock import ANY, AsyncMock, patch
from aiohttp.test_utils import TestClient
@@ -12,12 +13,13 @@ import voluptuous as vol
from homeassistant import config_entries as core_ce, data_entry_flow, loader
from homeassistant.components.config import config_entries
-from homeassistant.config_entries import HANDLERS, ConfigFlow
+from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.discovery_flow import DiscoveryKey
+from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.loader import IntegrationNotFound
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -729,27 +731,62 @@ async def test_get_progress_index(
mock_platform(hass, "test.config_flow", None)
ws_client = await hass_ws_client(hass)
+ mock_integration(
+ hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True))
+ )
+
+ entry = MockConfigEntry(domain="test", title="Test", entry_id="1234")
+ entry.add_to_hass(hass)
+
class TestFlow(core_ce.ConfigFlow):
VERSION = 5
- async def async_step_hassio(self, discovery_info):
+ async def async_step_hassio(
+ self, discovery_info: HassioServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a Hass.io discovery."""
return await self.async_step_account()
- async def async_step_account(self, user_input=None):
+ async def async_step_account(self, user_input: dict[str, Any] | None = None):
+ """Show a form to the user."""
return self.async_show_form(step_id="account")
+ async def async_step_user(self, user_input: dict[str, Any] | None = None):
+ """Handle a config flow initialized by the user."""
+ return await self.async_step_account()
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ):
+ """Handle a reconfiguration flow initialized by the user."""
+ nonlocal entry
+ assert self._get_reconfigure_entry() is entry
+ return await self.async_step_account()
+
with patch.dict(HANDLERS, {"test": TestFlow}):
- form = await hass.config_entries.flow.async_init(
+ form_hassio = await hass.config_entries.flow.async_init(
"test", context={"source": core_ce.SOURCE_HASSIO}
)
+ form_user = await hass.config_entries.flow.async_init(
+ "test", context={"source": core_ce.SOURCE_USER}
+ )
+ form_reconfigure = await hass.config_entries.flow.async_init(
+ "test", context={"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}
+ )
+
+ for form in (form_hassio, form_user, form_reconfigure):
+ assert form["type"] == data_entry_flow.FlowResultType.FORM
+ assert form["step_id"] == "account"
await ws_client.send_json({"id": 5, "type": "config_entries/flow/progress"})
response = await ws_client.receive_json()
assert response["success"]
+
+ # Active flows with SOURCE_USER and SOURCE_RECONFIGURE should be filtered out
assert response["result"] == [
{
- "flow_id": form["flow_id"],
+ "flow_id": form_hassio["flow_id"],
"handler": "test",
"step_id": "account",
"context": {"source": core_ce.SOURCE_HASSIO},
diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py
index 4550f2e08e5..ee133d3dddd 100644
--- a/tests/components/config/test_core.py
+++ b/tests/components/config/test_core.py
@@ -10,7 +10,7 @@ from homeassistant.components.config import core
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-from homeassistant.util import dt as dt_util, location
+from homeassistant.util import dt as dt_util, location as location_util
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from tests.common import MockUser
@@ -238,7 +238,7 @@ async def test_detect_config_fail(hass: HomeAssistant, client) -> None:
"""Test detect config."""
with patch(
"homeassistant.util.location.async_detect_location_info",
- return_value=location.LocationInfo(
+ return_value=location_util.LocationInfo(
ip=None,
country_code=None,
currency=None,
diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py
index 88245eb567f..10d453b17f1 100644
--- a/tests/components/config/test_script.py
+++ b/tests/components/config/test_script.py
@@ -13,7 +13,7 @@ from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
from tests.typing import ClientSessionGenerator
@@ -226,7 +226,7 @@ async def test_update_script_config_with_blueprint_substitution_error(
with patch(
"homeassistant.components.blueprint.models.BlueprintInputs.async_substitute",
- side_effect=yaml.UndefinedSubstitution("blah"),
+ side_effect=yaml_util.UndefinedSubstitution("blah"),
):
resp = await client.post(
"/api/config/script/config/moon",
diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py
index 6c937473ddc..1985c6e5c8c 100644
--- a/tests/components/configurator/test_init.py
+++ b/tests/components/configurator/test_init.py
@@ -5,7 +5,7 @@ from datetime import timedelta
from homeassistant.components import configurator
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed
@@ -14,9 +14,9 @@ async def test_request_least_info(hass: HomeAssistant) -> None:
"""Test request config with least amount of data."""
request_id = configurator.async_request_config(hass, "Test Request", lambda _: None)
- assert (
- len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1
- ), "No new service registered"
+ assert len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1, (
+ "No new service registered"
+ )
states = hass.states.async_all()
diff --git a/tests/components/conftest.py b/tests/components/conftest.py
index 534c471bf83..ebf390e30d7 100644
--- a/tests/components/conftest.py
+++ b/tests/components/conftest.py
@@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, Callable, Generator
from functools import lru_cache
from importlib.util import find_spec
from pathlib import Path
+import re
import string
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -40,7 +41,9 @@ from homeassistant.data_entry_flow import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.translation import async_get_translations
-from homeassistant.util import yaml
+from homeassistant.util import yaml as yaml_util
+
+from tests.common import QualityScaleStatus, get_quality_scale
if TYPE_CHECKING:
from homeassistant.components.hassio import AddonManager
@@ -51,6 +54,9 @@ if TYPE_CHECKING:
from .sensor.common import MockSensor
from .switch.common import MockSwitch
+# Regex for accessing the integration name from the test path
+RE_REQUEST_DOMAIN = re.compile(r".*tests\/components\/([^/]+)\/.*")
+
@pytest.fixture(scope="session", autouse=find_spec("zeroconf") is not None)
def patch_zeroconf_multiple_catcher() -> Generator[None]:
@@ -74,9 +80,15 @@ def prevent_io() -> Generator[None]:
@pytest.fixture
def entity_registry_enabled_by_default() -> Generator[None]:
"""Test fixture that ensures all entities are enabled in the registry."""
- with patch(
- "homeassistant.helpers.entity.Entity.entity_registry_enabled_default",
- return_value=True,
+ with (
+ patch(
+ "homeassistant.helpers.entity.Entity.entity_registry_enabled_default",
+ return_value=True,
+ ),
+ patch(
+ "homeassistant.components.device_tracker.config_entry.ScannerEntity.entity_registry_enabled_default",
+ return_value=True,
+ ),
):
yield
@@ -516,13 +528,14 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As
@pytest.fixture(name="supervisor_client")
def supervisor_client() -> Generator[AsyncMock]:
"""Mock the supervisor client."""
- mounts_info_mock = AsyncMock(spec_set=["mounts"])
+ mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"])
mounts_info_mock.mounts = []
supervisor_client = AsyncMock()
supervisor_client.addons = AsyncMock()
supervisor_client.discovery = AsyncMock()
supervisor_client.homeassistant = AsyncMock()
supervisor_client.host = AsyncMock()
+ supervisor_client.jobs = AsyncMock()
supervisor_client.mounts.info.return_value = mounts_info_mock
supervisor_client.os = AsyncMock()
supervisor_client.resolution = AsyncMock()
@@ -560,6 +573,10 @@ def supervisor_client() -> Generator[AsyncMock]:
"homeassistant.components.hassio.repairs.get_supervisor_client",
return_value=supervisor_client,
),
+ patch(
+ "homeassistant.components.hassio.update_helper.get_supervisor_client",
+ return_value=supervisor_client,
+ ),
):
yield supervisor_client
@@ -630,7 +647,7 @@ def ignore_translations() -> str | list[str]:
def _get_integration_quality_scale(integration: str) -> dict[str, Any]:
"""Get the quality scale for an integration."""
try:
- return yaml.load_yaml_dict(
+ return yaml_util.load_yaml_dict(
f"homeassistant/components/{integration}/quality_scale.yaml"
).get("rules", {})
except FileNotFoundError:
@@ -765,7 +782,7 @@ async def _check_config_flow_result_translations(
translation_errors,
category,
integration,
- f"{key_prefix}abort.{result["reason"]}",
+ f"{key_prefix}abort.{result['reason']}",
result["description_placeholders"],
)
@@ -798,12 +815,29 @@ async def _check_create_issue_translations(
)
+def _get_request_quality_scale(
+ request: pytest.FixtureRequest, rule: str
+) -> QualityScaleStatus:
+ if not (match := RE_REQUEST_DOMAIN.match(str(request.path))):
+ return QualityScaleStatus.TODO
+ integration = match.groups(1)[0]
+ return get_quality_scale(integration).get(rule, QualityScaleStatus.TODO)
+
+
async def _check_exception_translation(
hass: HomeAssistant,
exception: HomeAssistantError,
translation_errors: dict[str, str],
+ request: pytest.FixtureRequest,
) -> None:
if exception.translation_key is None:
+ if (
+ _get_request_quality_scale(request, "exception-translations")
+ is QualityScaleStatus.DONE
+ ):
+ translation_errors["quality_scale"] = (
+ f"Found untranslated {type(exception).__name__} exception: {exception}"
+ )
return
await _validate_translation(
hass,
@@ -817,13 +851,14 @@ async def _check_exception_translation(
@pytest.fixture(autouse=True)
async def check_translations(
- ignore_translations: str | list[str],
+ ignore_translations: str | list[str], request: pytest.FixtureRequest
) -> AsyncGenerator[None]:
"""Check that translation requirements are met.
Current checks:
- data entry flow results (ConfigFlow/OptionsFlow/RepairFlow)
- issue registry entries
+ - action (service) exceptions
"""
if not isinstance(ignore_translations, list):
ignore_translations = [ignore_translations]
@@ -881,7 +916,9 @@ async def check_translations(
)
except HomeAssistantError as err:
translation_coros.add(
- _check_exception_translation(self._hass, err, translation_errors)
+ _check_exception_translation(
+ self._hass, err, translation_errors, request
+ )
)
raise
diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr
index f1e220b10b2..c2b16ea2912 100644
--- a/tests/components/conversation/snapshots/test_default_agent.ambr
+++ b/tests/components/conversation/snapshots/test_default_agent.ambr
@@ -1,7 +1,7 @@
# serializer version: 1
# name: test_custom_sentences
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -26,7 +26,7 @@
# ---
# name: test_custom_sentences.1
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -51,7 +51,7 @@
# ---
# name: test_custom_sentences_config
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -76,7 +76,7 @@
# ---
# name: test_intent_alias_added_removed
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -106,7 +106,7 @@
# ---
# name: test_intent_alias_added_removed.1
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -136,7 +136,7 @@
# ---
# name: test_intent_alias_added_removed.2
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -156,7 +156,7 @@
# ---
# name: test_intent_conversion_not_expose_new
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -176,7 +176,7 @@
# ---
# name: test_intent_conversion_not_expose_new.1
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -206,7 +206,7 @@
# ---
# name: test_intent_entity_added_removed
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -236,7 +236,7 @@
# ---
# name: test_intent_entity_added_removed.1
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -266,7 +266,7 @@
# ---
# name: test_intent_entity_added_removed.2
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -296,7 +296,7 @@
# ---
# name: test_intent_entity_added_removed.3
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -316,7 +316,7 @@
# ---
# name: test_intent_entity_exposed
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -346,7 +346,7 @@
# ---
# name: test_intent_entity_fail_if_unexposed
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -366,7 +366,7 @@
# ---
# name: test_intent_entity_remove_custom_name
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -386,7 +386,7 @@
# ---
# name: test_intent_entity_remove_custom_name.1
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -416,7 +416,7 @@
# ---
# name: test_intent_entity_remove_custom_name.2
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -436,7 +436,7 @@
# ---
# name: test_intent_entity_renamed
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -466,7 +466,7 @@
# ---
# name: test_intent_entity_renamed.1
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr
index 0de575790db..c6ac6c2df9c 100644
--- a/tests/components/conversation/snapshots/test_http.ambr
+++ b/tests/components/conversation/snapshots/test_http.ambr
@@ -49,6 +49,7 @@
'sk',
'sl',
'sr',
+ 'sr-Latn',
'sv',
'sw',
'te',
@@ -201,7 +202,7 @@
# ---
# name: test_http_api_handle_failure
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -221,7 +222,7 @@
# ---
# name: test_http_api_no_match
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -241,7 +242,7 @@
# ---
# name: test_http_api_unexpected_failure
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -261,7 +262,7 @@
# ---
# name: test_http_processing_intent[None]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -291,7 +292,7 @@
# ---
# name: test_http_processing_intent[conversation.home_assistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -321,7 +322,7 @@
# ---
# name: test_http_processing_intent[homeassistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -351,7 +352,7 @@
# ---
# name: test_ws_api[payload0]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -371,7 +372,7 @@
# ---
# name: test_ws_api[payload1]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -391,7 +392,7 @@
# ---
# name: test_ws_api[payload2]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -411,7 +412,7 @@
# ---
# name: test_ws_api[payload3]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -431,7 +432,7 @@
# ---
# name: test_ws_api[payload4]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -451,7 +452,7 @@
# ---
# name: test_ws_api[payload5]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -539,7 +540,7 @@
'name': 'HassTurnOn',
}),
'match': True,
- 'sentence_template': ' on [] ',
+ 'sentence_template': ' on [(|)] ',
'slots': dict({
'area': 'kitchen',
'domain': 'light',
@@ -638,7 +639,7 @@
'brightness': dict({
'name': 'brightness',
'text': '100',
- 'value': 100,
+ 'value': 100.0,
}),
'name': dict({
'name': 'name',
@@ -690,7 +691,7 @@
'targets': dict({
}),
'unmatched_slots': dict({
- 'brightness': 1001,
+ 'brightness': 1001.0,
}),
}),
]),
diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr
index 0327be064d4..911c7043a6d 100644
--- a/tests/components/conversation/snapshots/test_init.ambr
+++ b/tests/components/conversation/snapshots/test_init.ambr
@@ -1,7 +1,7 @@
# serializer version: 1
# name: test_custom_agent
dict({
- 'conversation_id': 'test-conv-id',
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -44,7 +44,7 @@
# ---
# name: test_turn_on_intent[None-turn kitchen on-None]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -74,7 +74,7 @@
# ---
# name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -104,7 +104,7 @@
# ---
# name: test_turn_on_intent[None-turn kitchen on-homeassistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -134,7 +134,7 @@
# ---
# name: test_turn_on_intent[None-turn on kitchen-None]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -164,7 +164,7 @@
# ---
# name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -194,7 +194,7 @@
# ---
# name: test_turn_on_intent[None-turn on kitchen-homeassistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -224,7 +224,7 @@
# ---
# name: test_turn_on_intent[my_new_conversation-turn kitchen on-None]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -254,7 +254,7 @@
# ---
# name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -284,7 +284,7 @@
# ---
# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -314,7 +314,7 @@
# ---
# name: test_turn_on_intent[my_new_conversation-turn on kitchen-None]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -344,7 +344,7 @@
# ---
# name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
@@ -374,7 +374,7 @@
# ---
# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant]
dict({
- 'conversation_id': None,
+ 'conversation_id': ,
'response': dict({
'card': dict({
}),
diff --git a/tests/components/conversation/snapshots/test_session.ambr b/tests/components/conversation/snapshots/test_session.ambr
new file mode 100644
index 00000000000..4e94157c601
--- /dev/null
+++ b/tests/components/conversation/snapshots/test_session.ambr
@@ -0,0 +1,41 @@
+# serializer version: 1
+# name: test_template_error
+ dict({
+ 'conversation_id': ,
+ 'response': dict({
+ 'card': dict({
+ }),
+ 'data': dict({
+ 'code': 'unknown',
+ }),
+ 'language': 'en',
+ 'response_type': 'error',
+ 'speech': dict({
+ 'plain': dict({
+ 'extra_data': None,
+ 'speech': 'Sorry, I had a problem with my template',
+ }),
+ }),
+ }),
+ })
+# ---
+# name: test_unknown_llm_api
+ dict({
+ 'conversation_id': ,
+ 'response': dict({
+ 'card': dict({
+ }),
+ 'data': dict({
+ 'code': 'unknown',
+ }),
+ 'language': 'en',
+ 'response_type': 'error',
+ 'speech': dict({
+ 'plain': dict({
+ 'extra_data': None,
+ 'speech': 'Error preparing LLM API',
+ }),
+ }),
+ }),
+ })
+# ---
diff --git a/tests/components/conversation/test_agent_manager.py b/tests/components/conversation/test_agent_manager.py
index 47b58a522a8..3f98c9bcd69 100644
--- a/tests/components/conversation/test_agent_manager.py
+++ b/tests/components/conversation/test_agent_manager.py
@@ -22,6 +22,7 @@ async def test_async_converse(hass: HomeAssistant, init_components) -> None:
language="test lang",
agent_id="conversation.home_assistant",
device_id="test device id",
+ extra_system_prompt="test extra prompt",
)
assert mock_process.called
@@ -32,3 +33,4 @@ async def test_async_converse(hass: HomeAssistant, init_components) -> None:
assert conversation_input.language == "test lang"
assert conversation_input.agent_id == "conversation.home_assistant"
assert conversation_input.device_id == "test device id"
+ assert conversation_input.extra_system_prompt == "test extra prompt"
diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py
index 7e05476a349..54aa30b3fcf 100644
--- a/tests/components/conversation/test_default_agent.py
+++ b/tests/components/conversation/test_default_agent.py
@@ -11,7 +11,7 @@ import pytest
from syrupy import SnapshotAssertion
import yaml
-from homeassistant.components import conversation, cover, media_player
+from homeassistant.components import conversation, cover, media_player, weather
from homeassistant.components.conversation import default_agent
from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
@@ -399,9 +399,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
result = await conversation.async_converse(hass, sentence, None, Context())
assert callback.call_count == 1
assert callback.call_args[0][0].text == sentence
- assert (
- result.response.response_type == intent.IntentResponseType.ACTION_DONE
- ), sentence
+ assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, (
+ sentence
+ )
assert result.response.speech == {
"plain": {"speech": trigger_response, "extra_data": None}
}
@@ -412,9 +412,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
callback.reset_mock()
for sentence in test_sentences:
result = await conversation.async_converse(hass, sentence, None, Context())
- assert (
- result.response.response_type == intent.IntentResponseType.ERROR
- ), sentence
+ assert result.response.response_type == intent.IntentResponseType.ERROR, (
+ sentence
+ )
assert len(callback.mock_calls) == 0
@@ -3104,3 +3104,77 @@ async def test_turn_on_off(
)
assert len(off_calls) == 1
assert off_calls[0].data.get("entity_id") == [entity_id]
+
+
+@pytest.mark.parametrize(
+ ("error_code", "return_response"),
+ [
+ (intent.IntentResponseErrorCode.NO_INTENT_MATCH, False),
+ (intent.IntentResponseErrorCode.NO_VALID_TARGETS, False),
+ (intent.IntentResponseErrorCode.FAILED_TO_HANDLE, True),
+ (intent.IntentResponseErrorCode.UNKNOWN, True),
+ ],
+)
+@pytest.mark.usefixtures("init_components")
+async def test_handle_intents_with_response_errors(
+ hass: HomeAssistant,
+ init_components: None,
+ area_registry: ar.AreaRegistry,
+ error_code: intent.IntentResponseErrorCode,
+ return_response: bool,
+) -> None:
+ """Test that handle_intents does not return response errors."""
+ assert await async_setup_component(hass, "climate", {})
+ area_registry.async_create("living room")
+
+ agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY]
+
+ user_input = ConversationInput(
+ text="What is the temperature in the living room?",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+
+ with patch(
+ "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result",
+ return_value=default_agent._make_error_result(
+ user_input.language, error_code, "Mock error message"
+ ),
+ ) as mock_process:
+ response = await agent.async_handle_intents(user_input)
+
+ assert len(mock_process.mock_calls) == 1
+
+ if return_response:
+ assert response is not None and response.error_code == error_code
+ else:
+ assert response is None
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_state_names_are_not_translated(
+ hass: HomeAssistant,
+ init_components: None,
+) -> None:
+ """Test that state names are not translated in responses."""
+ await async_setup_component(hass, "weather", {})
+
+ hass.states.async_set("weather.test_weather", weather.ATTR_CONDITION_PARTLYCLOUDY)
+ expose_entity(hass, "weather.test_weather", True)
+
+ with patch(
+ "homeassistant.helpers.template.Template.async_render"
+ ) as mock_async_render:
+ result = await conversation.async_converse(
+ hass, "what is the weather like?", None, Context(), None
+ )
+ assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER
+ mock_async_render.assert_called_once()
+
+ assert (
+ mock_async_render.call_args.args[0]["state"].state
+ == weather.ATTR_CONDITION_PARTLYCLOUDY
+ )
diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py
index 109c0ed361f..f03b24818bf 100644
--- a/tests/components/conversation/test_entity.py
+++ b/tests/components/conversation/test_entity.py
@@ -6,7 +6,7 @@ from homeassistant.components import conversation
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import mock_restore_cache
diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py
new file mode 100644
index 00000000000..60c7f2957b8
--- /dev/null
+++ b/tests/components/conversation/test_session.py
@@ -0,0 +1,492 @@
+"""Test the conversation session."""
+
+from collections.abc import Generator
+from datetime import timedelta
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+from syrupy.assertion import SnapshotAssertion
+import voluptuous as vol
+
+from homeassistant.components.conversation import ConversationInput, session
+from homeassistant.core import Context, HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import llm
+from homeassistant.util import dt as dt_util
+
+from tests.common import async_fire_time_changed
+
+
+@pytest.fixture
+def mock_conversation_input(hass: HomeAssistant) -> ConversationInput:
+ """Return a conversation input instance."""
+ return ConversationInput(
+ text="Hello",
+ context=Context(),
+ conversation_id=None,
+ agent_id="mock-agent-id",
+ device_id=None,
+ language="en",
+ )
+
+
+@pytest.fixture
+def mock_ulid() -> Generator[Mock]:
+ """Mock the ulid library."""
+ with patch("homeassistant.util.ulid.ulid_now") as mock_ulid_now:
+ mock_ulid_now.return_value = "mock-ulid"
+ yield mock_ulid_now
+
+
+@pytest.mark.parametrize(
+ ("start_id", "given_id"),
+ [
+ (None, "mock-ulid"),
+ # This ULID is not known as a session
+ ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"),
+ ("not-a-ulid", "not-a-ulid"),
+ ],
+)
+async def test_conversation_id(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+ mock_ulid: Mock,
+ start_id: str | None,
+ given_id: str,
+) -> None:
+ """Test conversation ID generation."""
+ mock_conversation_input.conversation_id = start_id
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ assert chat_session.conversation_id == given_id
+
+
+async def test_cleanup(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+) -> None:
+ """Mock cleanup of the conversation session."""
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ assert len(chat_session.messages) == 2
+ conversation_id = chat_session.conversation_id
+
+ # Generate session entry.
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ # Because we didn't add a message to the session in the last block,
+ # the conversation was not be persisted and we get a new ID
+ assert chat_session.conversation_id != conversation_id
+ conversation_id = chat_session.conversation_id
+ chat_session.async_add_message(
+ session.Content(
+ role="assistant",
+ agent_id="mock-agent-id",
+ content="Hey!",
+ )
+ )
+ assert len(chat_session.messages) == 3
+
+ # Reuse conversation ID to ensure we can chat with same session
+ mock_conversation_input.conversation_id = conversation_id
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ assert len(chat_session.messages) == 4
+ assert chat_session.conversation_id == conversation_id
+
+ # Set the last updated to be older than the timeout
+ hass.data[session.DATA_CHAT_HISTORY][conversation_id].last_updated = (
+ dt_util.utcnow() + session.CONVERSATION_TIMEOUT
+ )
+
+ async_fire_time_changed(
+ hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT + timedelta(seconds=1)
+ )
+
+ # Should not be cleaned up, but it should have scheduled another cleanup
+ mock_conversation_input.conversation_id = conversation_id
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ assert len(chat_session.messages) == 4
+ assert chat_session.conversation_id == conversation_id
+
+ async_fire_time_changed(
+ hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1)
+ )
+
+ # It should be cleaned up now and we start a new conversation
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ assert chat_session.conversation_id != conversation_id
+ assert len(chat_session.messages) == 2
+
+
+async def test_add_message(
+ hass: HomeAssistant, mock_conversation_input: ConversationInput
+) -> None:
+ """Test filtering of messages."""
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ assert len(chat_session.messages) == 2
+
+ with pytest.raises(ValueError):
+ chat_session.async_add_message(
+ session.Content(role="system", agent_id=None, content="")
+ )
+
+ # No 2 user messages in a row
+ assert chat_session.messages[1].role == "user"
+
+ with pytest.raises(ValueError):
+ chat_session.async_add_message(
+ session.Content(role="user", agent_id=None, content="")
+ )
+
+ # No 2 assistant messages in a row
+ chat_session.async_add_message(
+ session.Content(role="assistant", agent_id=None, content="")
+ )
+ assert len(chat_session.messages) == 3
+ assert chat_session.messages[-1].role == "assistant"
+
+ with pytest.raises(ValueError):
+ chat_session.async_add_message(
+ session.Content(role="assistant", agent_id=None, content="")
+ )
+
+
+async def test_message_filtering(
+ hass: HomeAssistant, mock_conversation_input: ConversationInput
+) -> None:
+ """Test filtering of messages."""
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ messages = chat_session.async_get_messages(agent_id=None)
+ assert len(messages) == 2
+ assert messages[0] == session.Content(
+ role="system",
+ agent_id=None,
+ content="",
+ )
+ assert messages[1] == session.Content(
+ role="user",
+ agent_id="mock-agent-id",
+ content=mock_conversation_input.text,
+ )
+ # Cannot add a second user message in a row
+ with pytest.raises(ValueError):
+ chat_session.async_add_message(
+ session.Content(
+ role="user",
+ agent_id="mock-agent-id",
+ content="Hey!",
+ )
+ )
+
+ chat_session.async_add_message(
+ session.Content(
+ role="assistant",
+ agent_id="mock-agent-id",
+ content="Hey!",
+ )
+ )
+ # Different agent, native messages will be filtered out.
+ chat_session.async_add_message(
+ session.NativeContent(agent_id="another-mock-agent-id", content=1)
+ )
+ chat_session.async_add_message(
+ session.NativeContent(agent_id="mock-agent-id", content=1)
+ )
+ # A non-native message from another agent is not filtered out.
+ chat_session.async_add_message(
+ session.Content(
+ role="assistant",
+ agent_id="another-mock-agent-id",
+ content="Hi!",
+ )
+ )
+
+ assert len(chat_session.messages) == 6
+
+ messages = chat_session.async_get_messages(agent_id="mock-agent-id")
+ assert len(messages) == 5
+
+ assert messages[2] == session.Content(
+ role="assistant",
+ agent_id="mock-agent-id",
+ content="Hey!",
+ )
+ assert messages[3] == session.NativeContent(agent_id="mock-agent-id", content=1)
+ assert messages[4] == session.Content(
+ role="assistant", agent_id="another-mock-agent-id", content="Hi!"
+ )
+
+
+async def test_llm_api(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+) -> None:
+ """Test when we reference an LLM API."""
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api="assist",
+ user_llm_prompt=None,
+ )
+
+ assert isinstance(chat_session.llm_api, llm.APIInstance)
+ assert chat_session.llm_api.api.id == "assist"
+
+
+async def test_unknown_llm_api(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test when we reference an LLM API that does not exists."""
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ with pytest.raises(session.ConverseError) as exc_info:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api="unknown-api",
+ user_llm_prompt=None,
+ )
+
+ assert str(exc_info.value) == "Error getting LLM API unknown-api"
+ assert exc_info.value.as_conversation_result().as_dict() == snapshot
+
+
+async def test_template_error(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test that template error handling works."""
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ with pytest.raises(session.ConverseError) as exc_info:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api=None,
+ user_llm_prompt="{{ invalid_syntax",
+ )
+
+ assert str(exc_info.value) == "Error rendering prompt"
+ assert exc_info.value.as_conversation_result().as_dict() == snapshot
+
+
+async def test_template_variables(
+ hass: HomeAssistant, mock_conversation_input: ConversationInput
+) -> None:
+ """Test that template variables work."""
+ mock_user = Mock()
+ mock_user.id = "12345"
+ mock_user.name = "Test User"
+ mock_conversation_input.context = Context(user_id=mock_user.id)
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ with patch(
+ "homeassistant.auth.AuthManager.async_get_user", return_value=mock_user
+ ):
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api=None,
+ user_llm_prompt=(
+ "The instance name is {{ ha_name }}. "
+ "The user name is {{ user_name }}. "
+ "The user id is {{ llm_context.context.user_id }}."
+ "The calling platform is {{ llm_context.platform }}."
+ ),
+ )
+
+ assert chat_session.user_name == "Test User"
+
+ assert "The instance name is test home." in chat_session.messages[0].content
+ assert "The user name is Test User." in chat_session.messages[0].content
+ assert "The user id is 12345." in chat_session.messages[0].content
+ assert "The calling platform is test." in chat_session.messages[0].content
+
+
+async def test_extra_systen_prompt(
+ hass: HomeAssistant, mock_conversation_input: ConversationInput
+) -> None:
+ """Test that extra system prompt works."""
+ extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it."
+ extra_system_prompt2 = (
+ "User person.paulus came home. Asked him what he wants to do."
+ )
+ mock_conversation_input.extra_system_prompt = extra_system_prompt
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api=None,
+ user_llm_prompt=None,
+ )
+ chat_session.async_add_message(
+ session.Content(
+ role="assistant",
+ agent_id="mock-agent-id",
+ content="Hey!",
+ )
+ )
+
+ assert chat_session.extra_system_prompt == extra_system_prompt
+ assert chat_session.messages[0].content.endswith(extra_system_prompt)
+
+ # Verify that follow-up conversations with no system prompt take previous one
+ mock_conversation_input.conversation_id = chat_session.conversation_id
+ mock_conversation_input.extra_system_prompt = None
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api=None,
+ user_llm_prompt=None,
+ )
+
+ assert chat_session.extra_system_prompt == extra_system_prompt
+ assert chat_session.messages[0].content.endswith(extra_system_prompt)
+
+ # Verify that we take new system prompts
+ mock_conversation_input.extra_system_prompt = extra_system_prompt2
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api=None,
+ user_llm_prompt=None,
+ )
+ chat_session.async_add_message(
+ session.Content(
+ role="assistant",
+ agent_id="mock-agent-id",
+ content="Hey!",
+ )
+ )
+
+ assert chat_session.extra_system_prompt == extra_system_prompt2
+ assert chat_session.messages[0].content.endswith(extra_system_prompt2)
+ assert extra_system_prompt not in chat_session.messages[0].content
+
+ # Verify that follow-up conversations with no system prompt take previous one
+ mock_conversation_input.extra_system_prompt = None
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api=None,
+ user_llm_prompt=None,
+ )
+
+ assert chat_session.extra_system_prompt == extra_system_prompt2
+ assert chat_session.messages[0].content.endswith(extra_system_prompt2)
+
+
+async def test_tool_call(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+) -> None:
+ """Test using the session tool calling API."""
+
+ 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"
+
+ with patch(
+ "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools",
+ return_value=[],
+ ) as mock_get_tools:
+ mock_get_tools.return_value = [mock_tool]
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api="assist",
+ user_llm_prompt=None,
+ )
+ result = await chat_session.async_call_tool(
+ llm.ToolInput(
+ tool_name="test_tool",
+ tool_args={"param1": "Test Param"},
+ )
+ )
+
+ assert result == "Test response"
+
+
+async def test_tool_call_exception(
+ hass: HomeAssistant,
+ mock_conversation_input: ConversationInput,
+) -> None:
+ """Test using the session tool calling API."""
+
+ 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 error")
+
+ with patch(
+ "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools",
+ return_value=[],
+ ) as mock_get_tools:
+ mock_get_tools.return_value = [mock_tool]
+
+ async with session.async_get_chat_session(
+ hass, mock_conversation_input
+ ) as chat_session:
+ await chat_session.async_update_llm_data(
+ conversing_domain="test",
+ user_input=mock_conversation_input,
+ user_llm_hass_api="assist",
+ user_llm_prompt=None,
+ )
+ result = await chat_session.async_call_tool(
+ llm.ToolInput(
+ tool_name="test_tool",
+ tool_args={"param1": "Test Param"},
+ )
+ )
+
+ assert result == {"error": "HomeAssistantError", "error_text": "Test error"}
diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py
index 7c00b9a80b2..a975c9b7983 100644
--- a/tests/components/conversation/test_trace.py
+++ b/tests/components/conversation/test_trace.py
@@ -61,18 +61,18 @@ async def test_converation_trace(
}
-async def test_converation_trace_error(
+async def test_converation_trace_uncaught_error(
hass: HomeAssistant,
init_components: None,
sl_setup: None,
) -> None:
- """Test tracing a conversation."""
+ """Test tracing a conversation that raises an uncaught error."""
with (
patch(
"homeassistant.components.conversation.default_agent.DefaultAgent.async_process",
- side_effect=HomeAssistantError("Failed to talk to agent"),
+ side_effect=ValueError("Unexpected error"),
),
- pytest.raises(HomeAssistantError),
+ pytest.raises(ValueError),
):
await conversation.async_converse(
hass, "add apples to my shopping list", None, Context()
@@ -87,4 +87,35 @@ async def test_converation_trace_error(
assert (
trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS
)
- assert last_trace.get("error") == "Failed to talk to agent"
+ assert last_trace.get("error") == "Unexpected error"
+ assert not last_trace.get("result")
+
+
+async def test_converation_trace_homeassistant_error(
+ hass: HomeAssistant,
+ init_components: None,
+ sl_setup: None,
+) -> None:
+ """Test tracing a conversation with a HomeAssistant error."""
+ with (
+ patch(
+ "homeassistant.components.conversation.default_agent.DefaultAgent.async_process",
+ side_effect=HomeAssistantError("Failed to talk to agent"),
+ ),
+ ):
+ await conversation.async_converse(
+ hass, "add apples to my shopping list", None, Context()
+ )
+
+ traces = trace.async_get_traces()
+ assert traces
+ last_trace = traces[-1].as_dict()
+ assert last_trace.get("events")
+ assert len(last_trace.get("events")) == 1
+ trace_event = last_trace["events"][0]
+ assert (
+ trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS
+ )
+ result = last_trace.get("result")
+ assert result
+ assert result["response"]["speech"]["plain"]["speech"] == "Failed to talk to agent"
diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py
index 50fac51c87a..9b57bb43b58 100644
--- a/tests/components/conversation/test_trigger.py
+++ b/tests/components/conversation/test_trigger.py
@@ -88,6 +88,7 @@ async def test_if_fires_on_event(
"device_id": None,
"language": "en",
"text": "Ha ha ha",
+ "extra_system_prompt": None,
},
}
@@ -235,6 +236,7 @@ async def test_response_same_sentence(
"device_id": None,
"language": "en",
"text": "test sentence",
+ "extra_system_prompt": None,
},
}
@@ -412,6 +414,7 @@ async def test_same_trigger_multiple_sentences(
"device_id": None,
"language": "en",
"text": "hello",
+ "extra_system_prompt": None,
},
}
@@ -639,6 +642,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
"device_id": None,
"language": "en",
"text": "play the white album by the beatles",
+ "extra_system_prompt": None,
},
}
diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py
index 68700967d35..7d84e7ac83e 100644
--- a/tests/components/cookidoo/conftest.py
+++ b/tests/components/cookidoo/conftest.py
@@ -8,6 +8,8 @@ from cookidoo_api import (
CookidooAdditionalItem,
CookidooAuthResponse,
CookidooIngredientItem,
+ CookidooSubscription,
+ CookidooUserInfo,
)
import pytest
@@ -21,6 +23,8 @@ PASSWORD = "test-password"
COUNTRY = "CH"
LANGUAGE = "de-CH"
+TEST_UUID = "sub_uuid"
+
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@@ -34,16 +38,10 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture
def mock_cookidoo_client() -> Generator[AsyncMock]:
"""Mock a Cookidoo client."""
- with (
- patch(
- "homeassistant.components.cookidoo.Cookidoo",
- autospec=True,
- ) as mock_client,
- patch(
- "homeassistant.components.cookidoo.config_flow.Cookidoo",
- new=mock_client,
- ),
- ):
+ with patch(
+ "homeassistant.components.cookidoo.helpers.Cookidoo",
+ autospec=True,
+ ) as mock_client:
client = mock_client.return_value
client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"})
client.get_ingredient_items.return_value = [
@@ -58,6 +56,15 @@ def mock_cookidoo_client() -> Generator[AsyncMock]:
"data"
]
]
+ client.get_active_subscription.return_value = CookidooSubscription(
+ **load_json_object_fixture("subscriptions.json", DOMAIN)["data"]
+ )
+ client.get_user_info.return_value = CookidooUserInfo(
+ **load_json_object_fixture("user_info.json", DOMAIN)["data"]
+ )
+ client.login.return_value = CookidooAuthResponse(
+ **load_json_object_fixture("login.json", DOMAIN)
+ )
yield client
@@ -66,6 +73,8 @@ def mock_cookidoo_config_entry() -> MockConfigEntry:
"""Mock cookidoo configuration entry."""
return MockConfigEntry(
domain=DOMAIN,
+ version=1,
+ minor_version=2,
data={
CONF_EMAIL: EMAIL,
CONF_PASSWORD: PASSWORD,
@@ -73,4 +82,5 @@ def mock_cookidoo_config_entry() -> MockConfigEntry:
CONF_LANGUAGE: LANGUAGE,
},
entry_id="01JBVVVJ87F6G5V0QJX6HBC94T",
+ unique_id=TEST_UUID,
)
diff --git a/tests/components/cookidoo/fixtures/login.json b/tests/components/cookidoo/fixtures/login.json
new file mode 100644
index 00000000000..e7bd6e8716c
--- /dev/null
+++ b/tests/components/cookidoo/fixtures/login.json
@@ -0,0 +1,7 @@
+{
+ "access_token": "eyJhbGci",
+ "expires_in": 43199,
+ "refresh_token": "eyJhbGciOiJSUzI1NiI",
+ "token_type": "bearer",
+ "sub": "sub_uuid"
+}
diff --git a/tests/components/cookidoo/fixtures/subscriptions.json b/tests/components/cookidoo/fixtures/subscriptions.json
new file mode 100644
index 00000000000..12b74b3af08
--- /dev/null
+++ b/tests/components/cookidoo/fixtures/subscriptions.json
@@ -0,0 +1,12 @@
+{
+ "data": {
+ "active": true,
+ "start_date": "2024-12-16T00:00:00Z",
+ "expires": "2025-12-16T23:59:00Z",
+ "type": "REGULAR",
+ "extended_type": "REGULAR",
+ "subscription_level": "FULL",
+ "subscription_source": "COMMERCE",
+ "status": "ACTIVE"
+ }
+}
diff --git a/tests/components/cookidoo/fixtures/user_info.json b/tests/components/cookidoo/fixtures/user_info.json
new file mode 100644
index 00000000000..1c99ae84823
--- /dev/null
+++ b/tests/components/cookidoo/fixtures/user_info.json
@@ -0,0 +1,7 @@
+{
+ "data": {
+ "username": "username_1234",
+ "description": null,
+ "picture": null
+ }
+}
diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr
new file mode 100644
index 00000000000..a6223059aa1
--- /dev/null
+++ b/tests/components/cookidoo/snapshots/test_button.ambr
@@ -0,0 +1,47 @@
+# serializer version: 1
+# name: test_all_entities[button.cookidoo_clear_shopping_list_and_additional_purchases-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'button',
+ 'entity_category': None,
+ 'entity_id': 'button.cookidoo_clear_shopping_list_and_additional_purchases',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Clear shopping list and additional purchases',
+ 'platform': 'cookidoo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'todo_clear',
+ 'unique_id': 'sub_uuid_todo_clear',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[button.cookidoo_clear_shopping_list_and_additional_purchases-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Cookidoo Clear shopping list and additional purchases',
+ }),
+ 'context': ,
+ 'entity_id': 'button.cookidoo_clear_shopping_list_and_additional_purchases',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..3dc799c1108
--- /dev/null
+++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr
@@ -0,0 +1,43 @@
+# serializer version: 1
+# name: test_diagnostics
+ dict({
+ 'data': dict({
+ 'additional_items': list([
+ dict({
+ 'id': 'unique_id_tomaten',
+ 'is_owned': False,
+ 'name': 'Tomaten',
+ }),
+ ]),
+ 'ingredient_items': list([
+ dict({
+ 'description': '200 g',
+ 'id': 'unique_id_mehl',
+ 'is_owned': False,
+ 'name': 'Mehl',
+ }),
+ ]),
+ 'subscription': dict({
+ 'active': True,
+ 'expires': '2025-12-16T23:59:00Z',
+ 'extended_type': 'REGULAR',
+ 'start_date': '2024-12-16T00:00:00Z',
+ 'status': 'ACTIVE',
+ 'subscription_level': 'FULL',
+ 'subscription_source': 'COMMERCE',
+ 'type': 'REGULAR',
+ }),
+ }),
+ 'entry_data': dict({
+ 'country': 'CH',
+ 'email': 'test-email',
+ 'language': 'de-CH',
+ 'password': '**REDACTED**',
+ }),
+ 'user': dict({
+ 'description': None,
+ 'picture': None,
+ 'username': 'username_1234',
+ }),
+ })
+# ---
diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..568b0baf688
--- /dev/null
+++ b/tests/components/cookidoo/snapshots/test_sensor.ambr
@@ -0,0 +1,106 @@
+# serializer version: 1
+# name: test_setup[sensor.cookidoo_subscription-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'free',
+ 'trial',
+ 'premium',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.cookidoo_subscription',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Subscription',
+ 'platform': 'cookidoo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': ,
+ 'unique_id': 'sub_uuid_subscription',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_setup[sensor.cookidoo_subscription-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'enum',
+ 'friendly_name': 'Cookidoo Subscription',
+ 'options': list([
+ 'free',
+ 'trial',
+ 'premium',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.cookidoo_subscription',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'premium',
+ })
+# ---
+# name: test_setup[sensor.cookidoo_subscription_expiration_date-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.cookidoo_subscription_expiration_date',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Subscription expiration date',
+ 'platform': 'cookidoo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': ,
+ 'unique_id': 'sub_uuid_expires',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_setup[sensor.cookidoo_subscription_expiration_date-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
+ 'friendly_name': 'Cookidoo Subscription expiration date',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.cookidoo_subscription_expiration_date',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2025-12-16T23:59:00+00:00',
+ })
+# ---
diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr
index 965cbb0adde..be641432929 100644
--- a/tests/components/cookidoo/snapshots/test_todo.ambr
+++ b/tests/components/cookidoo/snapshots/test_todo.ambr
@@ -28,7 +28,7 @@
'previous_unique_id': None,
'supported_features': ,
'translation_key': 'additional_item_list',
- 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items',
+ 'unique_id': 'sub_uuid_additional_items',
'unit_of_measurement': None,
})
# ---
@@ -75,7 +75,7 @@
'previous_unique_id': None,
'supported_features': ,
'translation_key': 'ingredient_list',
- 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_ingredients',
+ 'unique_id': 'sub_uuid_ingredients',
'unit_of_measurement': None,
})
# ---
diff --git a/tests/components/cookidoo/test_button.py b/tests/components/cookidoo/test_button.py
new file mode 100644
index 00000000000..3e832ec9fe6
--- /dev/null
+++ b/tests/components/cookidoo/test_button.py
@@ -0,0 +1,84 @@
+"""Tests for the Cookidoo button platform."""
+
+from unittest.mock import AsyncMock, patch
+
+from cookidoo_api import CookidooRequestException
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import ATTR_ENTITY_ID, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_all_entities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_cookidoo_client: AsyncMock,
+ cookidoo_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test all entities."""
+ with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.BUTTON]):
+ await setup_integration(hass, cookidoo_config_entry)
+
+ assert cookidoo_config_entry.state is ConfigEntryState.LOADED
+
+ await snapshot_platform(
+ hass, entity_registry, snapshot, cookidoo_config_entry.entry_id
+ )
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_pressing_button(
+ hass: HomeAssistant,
+ mock_cookidoo_client: AsyncMock,
+ cookidoo_config_entry: MockConfigEntry,
+) -> None:
+ """Test pressing button."""
+ await setup_integration(hass, cookidoo_config_entry)
+
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {
+ ATTR_ENTITY_ID: "button.cookidoo_clear_shopping_list_and_additional_purchases",
+ },
+ blocking=True,
+ )
+ mock_cookidoo_client.clear_shopping_list.assert_called_once()
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_pressing_button_exception(
+ hass: HomeAssistant,
+ mock_cookidoo_client: AsyncMock,
+ cookidoo_config_entry: MockConfigEntry,
+) -> None:
+ """Test pressing button with exception."""
+
+ await setup_integration(hass, cookidoo_config_entry)
+
+ assert cookidoo_config_entry.state is ConfigEntryState.LOADED
+
+ mock_cookidoo_client.clear_shopping_list.side_effect = CookidooRequestException
+ with pytest.raises(
+ HomeAssistantError,
+ match="Failed to clear all items from the Cookidoo shopping list",
+ ):
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {
+ ATTR_ENTITY_ID: "button.cookidoo_clear_shopping_list_and_additional_purchases",
+ },
+ blocking=True,
+ )
diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py
index 0057bb3767e..069442517a0 100644
--- a/tests/components/cookidoo/test_config_flow.py
+++ b/tests/components/cookidoo/test_config_flow.py
@@ -200,7 +200,12 @@ async def test_flow_reconfigure_success(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"},
+ user_input={
+ **MOCK_DATA_USER_STEP,
+ CONF_EMAIL: "new-email",
+ CONF_PASSWORD: "new-password",
+ CONF_COUNTRY: "DE",
+ },
)
assert result["type"] is FlowResultType.FORM
@@ -215,6 +220,8 @@ async def test_flow_reconfigure_success(
assert result["reason"] == "reconfigure_successful"
assert cookidoo_config_entry.data == {
**MOCK_DATA_USER_STEP,
+ CONF_EMAIL: "new-email",
+ CONF_PASSWORD: "new-password",
CONF_COUNTRY: "DE",
CONF_LANGUAGE: "de-DE",
}
@@ -340,6 +347,35 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2(
assert len(hass.config_entries.async_entries()) == 1
+async def test_flow_reconfigure_id_mismatch(
+ hass: HomeAssistant,
+ mock_cookidoo_client: AsyncMock,
+ cookidoo_config_entry: MockConfigEntry,
+) -> None:
+ """Test we abort when the new config is not for the same user."""
+
+ cookidoo_config_entry.add_to_hass(hass)
+ hass.config_entries.async_update_entry(
+ cookidoo_config_entry, unique_id="some_other_uuid"
+ )
+
+ result = await cookidoo_config_entry.start_reconfigure_flow(hass)
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ **MOCK_DATA_USER_STEP,
+ CONF_EMAIL: "new-email",
+ CONF_PASSWORD: "new-password",
+ CONF_COUNTRY: "DE",
+ },
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "unique_id_mismatch"
+
+
async def test_flow_reauth(
hass: HomeAssistant,
mock_cookidoo_client: AsyncMock,
@@ -419,46 +455,26 @@ async def test_flow_reauth_error_and_recover(
assert len(hass.config_entries.async_entries()) == 1
-@pytest.mark.parametrize(
- ("new_email", "saved_email", "result_reason"),
- [
- (EMAIL, EMAIL, "reauth_successful"),
- ("another-email", EMAIL, "already_configured"),
- ],
-)
-async def test_flow_reauth_init_data_already_configured(
+async def test_flow_reauth_id_mismatch(
hass: HomeAssistant,
mock_cookidoo_client: AsyncMock,
cookidoo_config_entry: MockConfigEntry,
- new_email: str,
- saved_email: str,
- result_reason: str,
) -> None:
- """Test we abort user data set when entry is already configured."""
+ """Test we abort when the new auth is not for the same user."""
cookidoo_config_entry.add_to_hass(hass)
-
- another_cookidoo_config_entry = MockConfigEntry(
- domain=DOMAIN,
- data={
- CONF_EMAIL: "another-email",
- CONF_PASSWORD: PASSWORD,
- CONF_COUNTRY: COUNTRY,
- CONF_LANGUAGE: LANGUAGE,
- },
+ hass.config_entries.async_update_entry(
+ cookidoo_config_entry, unique_id="some_other_uuid"
)
- another_cookidoo_config_entry.add_to_hass(hass)
-
result = await cookidoo_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- {CONF_EMAIL: new_email, CONF_PASSWORD: PASSWORD},
+ {CONF_EMAIL: "new-email", CONF_PASSWORD: PASSWORD},
)
assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == result_reason
- assert cookidoo_config_entry.data[CONF_EMAIL] == saved_email
+ assert result["reason"] == "unique_id_mismatch"
diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py
new file mode 100644
index 00000000000..c253e1f6e09
--- /dev/null
+++ b/tests/components/cookidoo/test_diagnostics.py
@@ -0,0 +1,29 @@
+"""Tests for the diagnostics data provided by the Cookidoo integration."""
+
+from unittest.mock import AsyncMock
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+
+async def test_diagnostics(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ mock_cookidoo_client: AsyncMock,
+ cookidoo_config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test diagnostics."""
+ await setup_integration(hass, cookidoo_config_entry)
+
+ assert (
+ await get_diagnostics_for_config_entry(hass, hass_client, cookidoo_config_entry)
+ == snapshot
+ )
diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py
index b1b9b880526..e97bf93bb21 100644
--- a/tests/components/cookidoo/test_init.py
+++ b/tests/components/cookidoo/test_init.py
@@ -7,9 +7,18 @@ import pytest
from homeassistant.components.cookidoo.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import (
+ CONF_COUNTRY,
+ CONF_EMAIL,
+ CONF_LANGUAGE,
+ CONF_PASSWORD,
+ Platform,
+)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
+from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID
from tests.common import MockConfigEntry
@@ -100,3 +109,229 @@ async def test_config_entry_not_ready_auth_error(
await hass.async_block_till_done()
assert cookidoo_config_entry.state is status
+
+
+MOCK_CONFIG_ENTRY_MIGRATION = {
+ CONF_EMAIL: EMAIL,
+ CONF_PASSWORD: PASSWORD,
+ CONF_COUNTRY: COUNTRY,
+ CONF_LANGUAGE: LANGUAGE,
+}
+
+OLD_ENTRY_ID = "OLD_OLD_ENTRY_ID"
+
+
+@pytest.mark.parametrize(
+ (
+ "from_version",
+ "from_minor_version",
+ "config_data",
+ "unique_id",
+ ),
+ [
+ (
+ 1,
+ 1,
+ MOCK_CONFIG_ENTRY_MIGRATION,
+ None,
+ ),
+ (1, 2, MOCK_CONFIG_ENTRY_MIGRATION, TEST_UUID),
+ ],
+)
+async def test_migration_from(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+ from_version,
+ from_minor_version,
+ config_data,
+ unique_id,
+ mock_cookidoo_client: AsyncMock,
+) -> None:
+ """Test different expected migration paths."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=config_data,
+ title=f"MIGRATION_TEST from {from_version}.{from_minor_version}",
+ version=from_version,
+ minor_version=from_minor_version,
+ unique_id=unique_id,
+ entry_id=OLD_ENTRY_ID,
+ )
+ config_entry.add_to_hass(hass)
+
+ device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, OLD_ENTRY_ID)},
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+ entity_registry.async_get_or_create(
+ config_entry=config_entry,
+ platform=DOMAIN,
+ domain="todo",
+ unique_id=f"{OLD_ENTRY_ID}_ingredients",
+ device_id=device.id,
+ )
+ entity_registry.async_get_or_create(
+ config_entry=config_entry,
+ platform=DOMAIN,
+ domain="todo",
+ unique_id=f"{OLD_ENTRY_ID}_additional_items",
+ device_id=device.id,
+ )
+ entity_registry.async_get_or_create(
+ config_entry=config_entry,
+ platform=DOMAIN,
+ domain="button",
+ unique_id=f"{OLD_ENTRY_ID}_todo_clear",
+ device_id=device.id,
+ )
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ # Check change in config entry and verify most recent version
+ assert config_entry.version == 1
+ assert config_entry.minor_version == 2
+ assert config_entry.unique_id == TEST_UUID
+
+ assert entity_registry.async_is_registered(
+ entity_registry.entities.get_entity_id(
+ (
+ Platform.TODO,
+ DOMAIN,
+ f"{TEST_UUID}_ingredients",
+ )
+ )
+ )
+ assert entity_registry.async_is_registered(
+ entity_registry.entities.get_entity_id(
+ (
+ Platform.TODO,
+ DOMAIN,
+ f"{TEST_UUID}_additional_items",
+ )
+ )
+ )
+ assert entity_registry.async_is_registered(
+ entity_registry.entities.get_entity_id(
+ (
+ Platform.BUTTON,
+ DOMAIN,
+ f"{TEST_UUID}_todo_clear",
+ )
+ )
+ )
+
+
+@pytest.mark.parametrize(
+ (
+ "from_version",
+ "from_minor_version",
+ "config_data",
+ "unique_id",
+ "login_exception",
+ ),
+ [
+ (
+ 1,
+ 1,
+ MOCK_CONFIG_ENTRY_MIGRATION,
+ None,
+ CookidooRequestException,
+ ),
+ (
+ 1,
+ 1,
+ MOCK_CONFIG_ENTRY_MIGRATION,
+ None,
+ CookidooAuthException,
+ ),
+ ],
+)
+async def test_migration_from_with_error(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+ from_version,
+ from_minor_version,
+ config_data,
+ unique_id,
+ login_exception: Exception,
+ mock_cookidoo_client: AsyncMock,
+) -> None:
+ """Test different expected migration paths but with connection issues."""
+ # Migration can fail due to connection issues as we have to fetch the uuid
+ mock_cookidoo_client.login.side_effect = login_exception
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=config_data,
+ title=f"MIGRATION_TEST from {from_version}.{from_minor_version} with login exception '{login_exception}'",
+ version=from_version,
+ minor_version=from_minor_version,
+ unique_id=unique_id,
+ entry_id=OLD_ENTRY_ID,
+ )
+ config_entry.add_to_hass(hass)
+
+ device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, OLD_ENTRY_ID)},
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+ entity_registry.async_get_or_create(
+ config_entry=config_entry,
+ platform=DOMAIN,
+ domain="todo",
+ unique_id=f"{OLD_ENTRY_ID}_ingredients",
+ device_id=device.id,
+ )
+ entity_registry.async_get_or_create(
+ config_entry=config_entry,
+ platform=DOMAIN,
+ domain="todo",
+ unique_id=f"{OLD_ENTRY_ID}_additional_items",
+ device_id=device.id,
+ )
+ entity_registry.async_get_or_create(
+ config_entry=config_entry,
+ platform=DOMAIN,
+ domain="button",
+ unique_id=f"{OLD_ENTRY_ID}_todo_clear",
+ device_id=device.id,
+ )
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
+
+ assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
+
+ assert entity_registry.async_is_registered(
+ entity_registry.entities.get_entity_id(
+ (
+ Platform.TODO,
+ DOMAIN,
+ f"{OLD_ENTRY_ID}_ingredients",
+ )
+ )
+ )
+ assert entity_registry.async_is_registered(
+ entity_registry.entities.get_entity_id(
+ (
+ Platform.TODO,
+ DOMAIN,
+ f"{OLD_ENTRY_ID}_additional_items",
+ )
+ )
+ )
+ assert entity_registry.async_is_registered(
+ entity_registry.entities.get_entity_id(
+ (
+ Platform.BUTTON,
+ DOMAIN,
+ f"{OLD_ENTRY_ID}_todo_clear",
+ )
+ )
+ )
diff --git a/tests/components/cookidoo/test_sensor.py b/tests/components/cookidoo/test_sensor.py
new file mode 100644
index 00000000000..d2ef88f2857
--- /dev/null
+++ b/tests/components/cookidoo/test_sensor.py
@@ -0,0 +1,44 @@
+"""Test for sensor platform of the Cookidoo integration."""
+
+from collections.abc import Generator
+from unittest.mock import patch
+
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.fixture(autouse=True)
+def sensor_only() -> Generator[None]:
+ """Enable only the sensor platform."""
+ with patch(
+ "homeassistant.components.cookidoo.PLATFORMS",
+ [Platform.SENSOR],
+ ):
+ yield
+
+
+@pytest.mark.usefixtures("mock_cookidoo_client")
+async def test_setup(
+ hass: HomeAssistant,
+ cookidoo_config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Snapshot test states of sensor platform."""
+
+ cookidoo_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(cookidoo_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert cookidoo_config_entry.state is ConfigEntryState.LOADED
+
+ await snapshot_platform(
+ hass, entity_registry, snapshot, cookidoo_config_entry.entry_id
+ )
diff --git a/tests/components/cookidoo/test_todo.py b/tests/components/cookidoo/test_todo.py
index 0e60a86d225..d66c4f357c2 100644
--- a/tests/components/cookidoo/test_todo.py
+++ b/tests/components/cookidoo/test_todo.py
@@ -50,7 +50,8 @@ async def test_todo(
) -> None:
"""Snapshot test states of todo platform."""
- await setup_integration(hass, cookidoo_config_entry)
+ with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.TODO]):
+ await setup_integration(hass, cookidoo_config_entry)
assert cookidoo_config_entry.state is ConfigEntryState.LOADED
diff --git a/tests/components/coolmaster/test_init.py b/tests/components/coolmaster/test_init.py
index 4a90d0d9276..f8ff761517f 100644
--- a/tests/components/coolmaster/test_init.py
+++ b/tests/components/coolmaster/test_init.py
@@ -1,6 +1,5 @@
"""The test for the Coolmaster integration."""
-from homeassistant.components.coolmaster.const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -20,8 +19,6 @@ async def test_unload_entry(
load_int: ConfigEntry,
) -> None:
"""Test Coolmaster unloading an entry."""
- assert load_int.entry_id in hass.data.get(DOMAIN)
await hass.config_entries.async_unload(load_int.entry_id)
await hass.async_block_till_done()
assert load_int.state is ConfigEntryState.NOT_LOADED
- assert not hass.data.get(DOMAIN)
diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py
index e6021d22326..7901baaa3b8 100644
--- a/tests/components/cover/test_device_trigger.py
+++ b/tests/components/cover/test_device_trigger.py
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_registry import RegistryEntryHider
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .common import MockCover
@@ -766,10 +766,7 @@ async def test_if_fires_on_position(
]
) == sorted(
[
- (
- f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open"
- " - None"
- ),
+ f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None",
f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None",
f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None",
]
@@ -925,10 +922,7 @@ async def test_if_fires_on_tilt_position(
]
) == sorted(
[
- (
- f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open"
- " - None"
- ),
+ f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None",
f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None",
f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None",
]
diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py
index 646c44e4ac2..f1997066638 100644
--- a/tests/components/cover/test_init.py
+++ b/tests/components/cover/test_init.py
@@ -13,7 +13,11 @@ from homeassistant.setup import async_setup_component
from .common import MockCover
-from tests.common import help_test_all, setup_test_component_platform
+from tests.common import (
+ MockEntityPlatform,
+ help_test_all,
+ setup_test_component_platform,
+)
async def test_services(
@@ -157,13 +161,17 @@ def test_all() -> None:
help_test_all(cover)
-def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
+def test_deprecated_supported_features_ints(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
"""Test deprecated supported features ints."""
class MockCoverEntity(cover.CoverEntity):
_attr_supported_features = 1
entity = MockCoverEntity()
+ entity.hass = hass
+ entity.platform = MockEntityPlatform(hass)
assert entity.supported_features is cover.CoverEntityFeature(1)
assert "MockCoverEntity" in caplog.text
assert "is using deprecated supported features values" in caplog.text
diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py
index a38a04cb2ad..c3bb17cb6d6 100644
--- a/tests/components/crownstone/test_config_flow.py
+++ b/tests/components/crownstone/test_config_flow.py
@@ -163,7 +163,7 @@ async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock):
async def start_options_flow(
- hass: HomeAssistant, entry_id: str, mocked_manager: MagicMock
+ hass: HomeAssistant, entry: MockConfigEntry, mocked_manager: MagicMock
):
"""Patch CrownstoneEntryManager and start the flow."""
# set up integration
@@ -171,9 +171,10 @@ async def start_options_flow(
"homeassistant.components.crownstone.CrownstoneEntryManager",
return_value=mocked_manager,
):
- await hass.config_entries.async_setup(entry_id)
+ await hass.config_entries.async_setup(entry.entry_id)
- return await hass.config_entries.options.async_init(entry_id)
+ entry.runtime_data = mocked_manager
+ return await hass.config_entries.options.async_init(entry.entry_id)
async def test_no_user_input(
@@ -413,7 +414,7 @@ async def test_options_flow_setup_usb(
result = await start_options_flow(
hass,
- entry.entry_id,
+ entry,
get_mocked_crownstone_entry_manager(
get_mocked_crownstone_cloud(create_mocked_spheres(2))
),
@@ -490,7 +491,7 @@ async def test_options_flow_remove_usb(hass: HomeAssistant) -> None:
result = await start_options_flow(
hass,
- entry.entry_id,
+ entry,
get_mocked_crownstone_entry_manager(
get_mocked_crownstone_cloud(create_mocked_spheres(2))
),
@@ -543,7 +544,7 @@ async def test_options_flow_manual_usb_path(
result = await start_options_flow(
hass,
- entry.entry_id,
+ entry,
get_mocked_crownstone_entry_manager(
get_mocked_crownstone_cloud(create_mocked_spheres(1))
),
@@ -602,7 +603,7 @@ async def test_options_flow_change_usb_sphere(hass: HomeAssistant) -> None:
result = await start_options_flow(
hass,
- entry.entry_id,
+ entry,
get_mocked_crownstone_entry_manager(
get_mocked_crownstone_cloud(create_mocked_spheres(3))
),
diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py
index 5c432e111dd..612ae7ab649 100644
--- a/tests/components/daikin/test_config_flow.py
+++ b/tests/components/daikin/test_config_flow.py
@@ -7,12 +7,12 @@ from aiohttp import ClientError, web_exceptions
from pydaikin.exceptions import DaikinException
import pytest
-from homeassistant.components import zeroconf
from homeassistant.components.daikin.const import KEY_MAC
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
@@ -121,7 +121,7 @@ async def test_api_password_abort(hass: HomeAssistant) -> None:
[
(
SOURCE_ZEROCONF,
- zeroconf.ZeroconfServiceInfo(
+ ZeroconfServiceInfo(
ip_address=ip_address(HOST),
ip_addresses=[ip_address(HOST)],
hostname="mock_hostname",
diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py
index fd3003b96ef..4a74a673ef8 100644
--- a/tests/components/deconz/conftest.py
+++ b/tests/components/deconz/conftest.py
@@ -19,9 +19,14 @@ from tests.common import MockConfigEntry
from tests.components.light.conftest import mock_light_profiles # noqa: F401
from tests.test_util.aiohttp import AiohttpClientMocker
-type ConfigEntryFactoryType = Callable[
- [MockConfigEntry], Coroutine[Any, Any, MockConfigEntry]
-]
+
+class ConfigEntryFactoryType(Protocol):
+ """Fixture factory that can set up deCONZ config entry."""
+
+ async def __call__(self, entry: MockConfigEntry = ..., /) -> MockConfigEntry:
+ """Set up a deCONZ config entry."""
+
+
type WebsocketDataType = Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
type WebsocketStateType = Callable[[str], Coroutine[Any, Any, None]]
@@ -203,10 +208,10 @@ async def fixture_config_entry_factory(
config_entry: MockConfigEntry,
mock_requests: Callable[[str], None],
) -> ConfigEntryFactoryType:
- """Fixture factory that can set up UniFi network integration."""
+ """Fixture factory that can set up deCONZ integration."""
async def __mock_setup_config_entry(
- entry: MockConfigEntry = config_entry,
+ entry: MockConfigEntry = config_entry, /
) -> MockConfigEntry:
entry.add_to_hass(hass)
mock_requests(entry.data[CONF_HOST])
diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py
index ce13bbfa5d4..fe5fe022427 100644
--- a/tests/components/deconz/test_config_flow.py
+++ b/tests/components/deconz/test_config_flow.py
@@ -6,7 +6,6 @@ from unittest.mock import patch
import pydeconz
import pytest
-from homeassistant.components import ssdp
from homeassistant.components.deconz.config_flow import (
CONF_MANUAL_INPUT,
CONF_SERIAL,
@@ -20,12 +19,16 @@ from homeassistant.components.deconz.const import (
DOMAIN as DECONZ_DOMAIN,
HASSIO_CONFIGURATION_URL,
)
-from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_SSDP, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MANUFACTURER_URL,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from .conftest import API_KEY, BRIDGE_ID
@@ -435,7 +438,7 @@ async def test_flow_ssdp_discovery(
"""Test that config flow for one discovered bridge works."""
result = await hass.config_entries.flow.async_init(
DECONZ_DOMAIN,
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://1.2.3.4:80/",
@@ -483,7 +486,7 @@ async def test_ssdp_discovery_update_configuration(
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DECONZ_DOMAIN,
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://2.3.4.5:80/",
@@ -509,7 +512,7 @@ async def test_ssdp_discovery_dont_update_configuration(
result = await hass.config_entries.flow.async_init(
DECONZ_DOMAIN,
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://1.2.3.4:80/",
@@ -533,7 +536,7 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration(
"""Test to ensure the SSDP discovery does not update an Hass.io entry."""
result = await hass.config_entries.flow.async_init(
DECONZ_DOMAIN,
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://1.2.3.4:80/",
diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py
index 43c51179337..1b000828b85 100644
--- a/tests/components/deconz/test_hub.py
+++ b/tests/components/deconz/test_hub.py
@@ -6,18 +6,18 @@ from pydeconz.websocket import State
import pytest
from syrupy import SnapshotAssertion
-from homeassistant.components import ssdp
from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL
from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN
-from homeassistant.components.ssdp import (
- ATTR_UPNP_MANUFACTURER_URL,
- ATTR_UPNP_SERIAL,
- ATTR_UPNP_UDN,
-)
from homeassistant.config_entries import SOURCE_SSDP
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MANUFACTURER_URL,
+ ATTR_UPNP_SERIAL,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .conftest import BRIDGE_ID
@@ -81,7 +81,7 @@ async def test_update_address(
):
await hass.config_entries.flow.async_init(
DECONZ_DOMAIN,
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_st="mock_st",
ssdp_usn="mock_usn",
ssdp_location="http://2.3.4.5:80/",
diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py
index 97cad5bbe14..dcec921c01d 100644
--- a/tests/components/demo/test_cover.py
+++ b/tests/components/demo/test_cover.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import assert_setup_component, async_fire_time_changed
diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py
index d3c2937d12b..a93c79828d6 100644
--- a/tests/components/demo/test_geo_location.py
+++ b/tests/components/demo/test_geo_location.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import assert_setup_component, async_fire_time_changed
diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py
index 98b3de8448a..f3677c6e373 100644
--- a/tests/components/demo/test_notify.py
+++ b/tests/components/demo/test_notify.py
@@ -6,8 +6,7 @@ from unittest.mock import patch
import pytest
from homeassistant.components import notify
-from homeassistant.components.demo import DOMAIN
-import homeassistant.components.demo.notify as demo
+from homeassistant.components.demo import DOMAIN, notify as demo
from homeassistant.const import Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.setup import async_setup_component
diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py
index 324b795052c..92fe381ac4d 100644
--- a/tests/components/denonavr/test_config_flow.py
+++ b/tests/components/denonavr/test_config_flow.py
@@ -5,7 +5,6 @@ from unittest.mock import patch
import pytest
from homeassistant import config_entries
-from homeassistant.components import ssdp
from homeassistant.components.denonavr.config_flow import (
CONF_MANUFACTURER,
CONF_SERIAL_NUMBER,
@@ -21,6 +20,12 @@ from homeassistant.components.denonavr.config_flow import (
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
from tests.common import MockConfigEntry
@@ -313,14 +318,14 @@ async def test_config_flow_ssdp(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=TEST_SSDP_LOCATION,
upnp={
- ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER,
- ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL,
- ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER,
+ ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME: TEST_MODEL,
+ ATTR_UPNP_SERIAL: TEST_SERIALNUMBER,
},
),
)
@@ -353,14 +358,14 @@ async def test_config_flow_ssdp_not_denon(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=TEST_SSDP_LOCATION,
upnp={
- ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported",
- ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL,
- ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER,
+ ATTR_UPNP_MANUFACTURER: "NotSupported",
+ ATTR_UPNP_MODEL_NAME: TEST_MODEL,
+ ATTR_UPNP_SERIAL: TEST_SERIALNUMBER,
},
),
)
@@ -377,12 +382,12 @@ async def test_config_flow_ssdp_missing_info(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=TEST_SSDP_LOCATION,
upnp={
- ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER,
+ ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER,
},
),
)
@@ -399,14 +404,14 @@ async def test_config_flow_ssdp_ignored_model(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=TEST_SSDP_LOCATION,
upnp={
- ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER,
- ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL,
- ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER,
+ ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL,
+ ATTR_UPNP_SERIAL: TEST_SERIALNUMBER,
},
),
)
diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py
index 4a4d8519b25..a543de974f1 100644
--- a/tests/components/derivative/test_sensor.py
+++ b/tests/components/derivative/test_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import UnitOfPower, UnitOfTime
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py
index 28ab6229c44..08ccaffa92d 100644
--- a/tests/components/devialet/__init__.py
+++ b/tests/components/devialet/__init__.py
@@ -5,10 +5,10 @@ from ipaddress import ip_address
from aiohttp import ClientError as ServerTimeoutError
from devialet.const import UrlSuffix
-from homeassistant.components import zeroconf
from homeassistant.components.devialet.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -25,7 +25,7 @@ CONF_DATA = {
MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]}
MOCK_USER_INPUT = {CONF_HOST: HOST}
-MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo(
+MOCK_ZEROCONF_DATA = ZeroconfServiceInfo(
ip_address=ip_address(HOST),
ip_addresses=[ip_address(HOST)],
hostname="PhantomISilver-L00P00000AB11.local.",
diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json
index 6ff1a724f08..3efe8ae2080 100644
--- a/tests/components/devialet/fixtures/general_info.json
+++ b/tests/components/devialet/fixtures/general_info.json
@@ -1,18 +1,22 @@
{
+ "availableFeatures": ["powerManagement"],
"deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78",
"deviceName": "Livingroom",
"firmwareFamily": "DOS",
"groupId": "12345678-901a-2b3c-def4-567g89h0i12j",
+ "installationId": "abc1eca1-1234-5251-a123-12ac1a3e9fe8",
"ipControlVersion": "1",
+ "isSystemLeader": true,
"model": "Phantom I Silver",
+ "modelFamily": "Phantom I",
+ "powerRating": "105 dB",
"release": {
"buildType": "release",
- "canonicalVersion": "2.16.1.49152",
- "version": "2.16.1"
+ "canonicalVersion": "2.17.6.49152",
+ "version": "2.17.6"
},
"role": "FrontLeft",
"serial": "L00P00000AB11",
- "standbyEntryDelay": 0,
- "standbyState": "Unknown",
+ "setupState": "ongoing",
"systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl"
}
diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json
index d389675ac98..f24575cd264 100644
--- a/tests/components/devialet/fixtures/source_state.json
+++ b/tests/components/devialet/fixtures/source_state.json
@@ -1,5 +1,5 @@
{
- "availableOptions": ["play", "pause", "previous", "next", "seek"],
+ "availableOperations": ["play", "pause", "previous", "next", "seek"],
"metadata": {
"album": "1 (Remastered)",
"artist": "The Beatles",
diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json
index f496e5557d2..a43b4e83901 100644
--- a/tests/components/devialet/fixtures/system_info.json
+++ b/tests/components/devialet/fixtures/system_info.json
@@ -1,6 +1,29 @@
{
- "availableFeatures": ["nightMode", "equalizer", "balance"],
+ "availableFeatures": [
+ "nightMode",
+ "equalizer",
+ "balance",
+ "preferredInterfaceControl"
+ ],
+ "devices": [
+ {
+ "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78",
+ "isSystemLeader": true,
+ "name": "Livingroom",
+ "role": null,
+ "serial": "L05P00066EW14"
+ },
+ {
+ "deviceId": "1zzyyxx2-3456-67g8-9h0i-1zz23456lz78",
+ "isSystemLeader": false,
+ "name": "Phantom I Silver-123a",
+ "role": null,
+ "serial": "M05P00066EW15"
+ }
+ ],
"groupId": "12345678-901a-2b3c-def4-567g89h0i12j",
+ "multiroomFamily": "sync33",
"systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl",
- "systemName": "Devialet"
+ "systemName": "Devialet",
+ "systemType": "stereo"
}
diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py
index 97c23efe713..6bf643ce682 100644
--- a/tests/components/devialet/test_diagnostics.py
+++ b/tests/components/devialet/test_diagnostics.py
@@ -31,11 +31,13 @@ async def test_diagnostics(
"source_list": [
"Airplay",
"Bluetooth",
- "Online",
"Optical left",
"Optical right",
"Raat",
"Spotify Connect",
+ "UPnP",
],
"source": "spotifyconnect",
+ "upnp_device_type": "Not available",
+ "upnp_device_url": "Not available",
}
diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py
index a87e8ac05c3..6808ee0983e 100644
--- a/tests/components/devialet/test_init.py
+++ b/tests/components/devialet/test_init.py
@@ -1,6 +1,5 @@
"""Test the Devialet init."""
-from homeassistant.components.devialet.const import DOMAIN
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -16,7 +15,6 @@ async def test_load_unload_config_entry(
"""Test the Devialet configuration entry loading and unloading."""
entry = await setup_integration(hass, aioclient_mock)
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
assert entry.unique_id is not None
@@ -26,7 +24,6 @@ async def test_load_unload_config_entry(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
@@ -36,7 +33,6 @@ async def test_load_unload_config_entry_when_device_unavailable(
"""Test the Devialet configuration entry loading and unloading when the device is unavailable."""
entry = await setup_integration(hass, aioclient_mock, state="unavailable")
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
assert entry.unique_id is not None
@@ -46,5 +42,4 @@ async def test_load_unload_config_entry_when_device_unavailable(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py
index 4e8f9b1dc03..fd593a10a98 100644
--- a/tests/components/devialet/test_media_player.py
+++ b/tests/components/devialet/test_media_player.py
@@ -6,7 +6,6 @@ from devialet import DevialetApi
from devialet.const import UrlSuffix
from yarl import URL
-from homeassistant.components.devialet.const import DOMAIN
from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET
from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY
from homeassistant.components.media_player import (
@@ -96,7 +95,7 @@ SERVICE_TO_DATA = {
],
SERVICE_SELECT_SOURCE: [
{ATTR_INPUT_SOURCE: "Optical left"},
- {ATTR_INPUT_SOURCE: "Online"},
+ {ATTR_INPUT_SOURCE: "UPnP"},
],
}
@@ -108,7 +107,6 @@ async def test_media_player_playing(
await async_setup_component(hass, "homeassistant", {})
entry = await setup_integration(hass, aioclient_mock)
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
await hass.services.async_call(
@@ -203,7 +201,7 @@ async def test_media_player_playing(
)
with patch.object(
- DevialetApi, "available_options", new_callable=PropertyMock
+ DevialetApi, "available_operations", new_callable=PropertyMock
) as mock:
mock.return_value = None
await hass.config_entries.async_reload(entry.entry_id)
@@ -227,7 +225,6 @@ async def test_media_player_playing(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
@@ -237,7 +234,6 @@ async def test_media_player_offline(
"""Test the Devialet configuration entry loading and unloading."""
entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE)
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}")
@@ -247,7 +243,6 @@ async def test_media_player_offline(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
@@ -257,14 +252,12 @@ async def test_media_player_without_serial(
"""Test the Devialet configuration entry loading and unloading."""
entry = await setup_integration(hass, aioclient_mock, serial=None)
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
assert entry.unique_id is None
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
@@ -276,7 +269,6 @@ async def test_media_player_services(
hass, aioclient_mock, state=MediaPlayerState.PLAYING
)
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id}
@@ -309,5 +301,4 @@ async def test_media_player_services(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py
index be4d3bd4c9e..a7b2f8a3b75 100644
--- a/tests/components/device_automation/test_toggle_entity.py
+++ b/tests/components/device_automation/test_toggle_entity.py
@@ -9,7 +9,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py
index e73c18919c5..ea07365bd2f 100644
--- a/tests/components/device_tracker/test_init.py
+++ b/tests/components/device_tracker/test_init.py
@@ -28,7 +28,7 @@ from homeassistant.helpers import discovery
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.json import JSONEncoder
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import common
from .common import MockScanner, mock_legacy_device_tracker_setup
@@ -159,9 +159,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None:
]
legacy.DeviceTracker(hass, False, True, {}, devices)
_LOGGER.debug(mock_warning.call_args_list)
- assert (
- mock_warning.call_count == 1
- ), "The only warning call should be duplicates (check DEBUG)"
+ assert mock_warning.call_count == 1, (
+ "The only warning call should be duplicates (check DEBUG)"
+ )
args, _ = mock_warning.call_args
assert "Duplicate device MAC" in args[0], "Duplicate MAC warning expected"
@@ -177,9 +177,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None:
legacy.DeviceTracker(hass, False, True, {}, devices)
_LOGGER.debug(mock_warning.call_args_list)
- assert (
- mock_warning.call_count == 1
- ), "The only warning call should be duplicates (check DEBUG)"
+ assert mock_warning.call_count == 1, (
+ "The only warning call should be duplicates (check DEBUG)"
+ )
args, _ = mock_warning.call_args
assert "Duplicate device IDs" in args[0], "Duplicate device IDs warning expected"
diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py
index 3351e42c988..06e7a8bcd9c 100644
--- a/tests/components/devolo_home_control/const.py
+++ b/tests/components/devolo_home_control/const.py
@@ -2,9 +2,9 @@
from ipaddress import ip_address
-from homeassistant.components import zeroconf
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
-DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
+DISCOVERY_INFO = ZeroconfServiceInfo(
ip_address=ip_address("192.168.0.1"),
ip_addresses=[ip_address("192.168.0.1")],
port=14791,
@@ -22,7 +22,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
},
)
-DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo(
+DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = ZeroconfServiceInfo(
ip_address=ip_address("192.168.0.1"),
ip_addresses=[ip_address("192.168.0.1")],
hostname="mock_hostname",
@@ -32,7 +32,7 @@ DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo(
type="mock_type",
)
-DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo(
+DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo(
ip_address=ip_address("192.168.0.1"),
ip_addresses=[ip_address("192.168.0.1")],
hostname="mock_hostname",
diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py
index 7b0551b1daf..f3c469e61b2 100644
--- a/tests/components/devolo_home_network/const.py
+++ b/tests/components/devolo_home_network/const.py
@@ -14,7 +14,7 @@ from devolo_plc_api.device_api import (
)
from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
IP = "192.0.2.1"
IP_ALT = "192.0.2.2"
diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py
index 6852f4369cc..223dc83f83a 100644
--- a/tests/components/dhcp/test_init.py
+++ b/tests/components/dhcp/test_init.py
@@ -31,16 +31,18 @@ from homeassistant.const import (
STATE_NOT_HOME,
)
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import (
MockConfigEntry,
MockModule,
async_fire_time_changed,
+ import_and_test_deprecated_constant,
mock_integration,
)
@@ -1353,3 +1355,30 @@ async def test_dhcp_rediscover_no_match(
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
+
+
+@pytest.mark.parametrize(
+ ("constant_name", "replacement_name", "replacement"),
+ [
+ (
+ "DhcpServiceInfo",
+ "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo",
+ DhcpServiceInfo,
+ ),
+ ],
+)
+def test_deprecated_constants(
+ caplog: pytest.LogCaptureFixture,
+ constant_name: str,
+ replacement_name: str,
+ replacement: Any,
+) -> None:
+ """Test deprecated automation constants."""
+ import_and_test_deprecated_constant(
+ caplog,
+ dhcp,
+ constant_name,
+ replacement_name,
+ replacement,
+ "2026.2",
+ )
diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py
index ae22e280000..48a334611d3 100644
--- a/tests/components/directv/__init__.py
+++ b/tests/components/directv/__init__.py
@@ -2,10 +2,10 @@
from http import HTTPStatus
-from homeassistant.components import ssdp
from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN
from homeassistant.const import CONF_HOST, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -16,7 +16,7 @@ SSDP_LOCATION = "http://127.0.0.1/"
UPNP_SERIAL = "RID-028877455858"
MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]}
-MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo(
+MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=SSDP_LOCATION,
diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py
index ad22aa871b7..b698873d1e9 100644
--- a/tests/components/directv/test_config_flow.py
+++ b/tests/components/directv/test_config_flow.py
@@ -6,11 +6,11 @@ from unittest.mock import patch
from aiohttp import ClientError as HTTPClientError
from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN
-from homeassistant.components.ssdp import ATTR_UPNP_SERIAL
from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL
from . import (
HOST,
diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py
index 4bfe8e2121f..102c338e757 100644
--- a/tests/components/directv/test_init.py
+++ b/tests/components/directv/test_init.py
@@ -1,6 +1,5 @@
"""Tests for the DirecTV integration."""
-from homeassistant.components.directv.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -24,11 +23,9 @@ async def test_unload_config_entry(
"""Test the DirecTV configuration entry unloading."""
entry = await setup_integration(hass, aioclient_mock)
- assert entry.entry_id in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
- assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py
index c56b93c4d3d..d59e06ef444 100644
--- a/tests/components/dlink/conftest.py
+++ b/tests/components/dlink/conftest.py
@@ -6,11 +6,11 @@ from unittest.mock import MagicMock, patch
import pytest
-from homeassistant.components import dhcp
from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -29,13 +29,13 @@ CONF_DHCP_DATA = {
CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST}
-CONF_DHCP_FLOW = dhcp.DhcpServiceInfo(
+CONF_DHCP_FLOW = DhcpServiceInfo(
ip=HOST,
macaddress=DHCP_FORMATTED_MAC,
hostname="dsp-w215",
)
-CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo(
+CONF_DHCP_FLOW_NEW_IP = DhcpServiceInfo(
ip="5.6.7.8",
macaddress=DHCP_FORMATTED_MAC,
hostname="dsp-w215",
diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py
index b6f025bb5b0..0449f68263c 100644
--- a/tests/components/dlink/test_config_flow.py
+++ b/tests/components/dlink/test_config_flow.py
@@ -2,12 +2,12 @@
from unittest.mock import MagicMock, patch
-from homeassistant.components import dhcp
from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import (
CONF_DATA,
@@ -160,7 +160,7 @@ async def test_dhcp_unique_id_assignment(
hass: HomeAssistant, mocked_plug: MagicMock
) -> None:
"""Test dhcp initialized flow with no unique id for matching entry."""
- dhcp_data = dhcp.DhcpServiceInfo(
+ dhcp_data = DhcpServiceInfo(
ip="2.3.4.5",
macaddress="11:22:33:44:55:66",
hostname="dsp-w215",
diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py
index cb32001e1e5..e02baceb380 100644
--- a/tests/components/dlna_dmr/test_config_flow.py
+++ b/tests/components/dlna_dmr/test_config_flow.py
@@ -12,7 +12,6 @@ from async_upnp_client.exceptions import UpnpError
import pytest
from homeassistant import config_entries
-from homeassistant.components import ssdp
from homeassistant.components.dlna_dmr.const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE,
@@ -23,6 +22,15 @@ from homeassistant.components.dlna_dmr.const import (
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_SERVICE_LIST,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .conftest import (
MOCK_DEVICE_HOST_ADDR,
@@ -48,17 +56,17 @@ CHANGED_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-badbadbadbad"
MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE"
-MOCK_DISCOVERY = ssdp.SsdpServiceInfo(
+MOCK_DISCOVERY = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_st=MOCK_DEVICE_TYPE,
ssdp_headers={"_host": MOCK_DEVICE_HOST_ADDR},
upnp={
- ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
- ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
- ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
- ssdp.ATTR_UPNP_SERVICE_LIST: {
+ ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
+ ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
+ ATTR_UPNP_SERVICE_LIST: {
"service": [
{
"SCPDURL": "/AVTransport/scpd.xml",
@@ -358,15 +366,15 @@ async def test_ssdp_flow_existing(
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_udn=MOCK_DEVICE_UDN,
upnp={
- ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
- ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
- ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
+ ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
+ ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
},
),
)
@@ -492,15 +500,15 @@ async def test_ssdp_flow_upnp_udn(
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_st=MOCK_DEVICE_TYPE,
upnp={
- ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE",
- ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
- ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
+ ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE",
+ ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
},
),
)
@@ -514,7 +522,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# No service list at all
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]
+ del discovery.upnp[ATTR_UPNP_SERVICE_LIST]
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
@@ -526,7 +534,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# Service list does not contain services
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = discovery.upnp.copy()
- discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"}
+ discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"}
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
@@ -538,10 +546,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# AVTransport service is missing
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {
+ discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {
"service": [
service
- for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"]
+ for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"]
if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport"
]
}
@@ -560,10 +568,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None:
"""
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = discovery.upnp.copy()
- service_list = discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST].copy()
+ service_list = discovery.upnp[ATTR_UPNP_SERVICE_LIST].copy()
# Turn mock's list of service dicts into a single dict
service_list["service"] = service_list["service"][0]
- discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list
+ discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
@@ -589,9 +597,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None:
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- discovery.upnp[ssdp.ATTR_UPNP_DEVICE_TYPE] = (
- "urn:schemas-upnp-org:device:ZonePlayer:1"
- )
+ discovery.upnp[ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1"
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
@@ -608,8 +614,8 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None:
):
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer
- discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model
+ discovery.upnp[ATTR_UPNP_MANUFACTURER] = manufacturer
+ discovery.upnp[ATTR_UPNP_MODEL_NAME] = model
result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py
index 3d8f9da8ed9..a92f7807912 100644
--- a/tests/components/dlna_dmr/test_media_player.py
+++ b/tests/components/dlna_dmr/test_media_player.py
@@ -47,6 +47,7 @@ from homeassistant.const import (
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.setup import async_setup_component
from .conftest import (
@@ -1413,7 +1414,7 @@ async def test_become_available(
# Send an SSDP notification from the now alive device
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1484,7 +1485,7 @@ async def test_alive_but_gone(
# Send an SSDP notification from the still missing device
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1506,7 +1507,7 @@ async def test_alive_but_gone(
# Send the same SSDP notification, expecting no extra connection attempts
domain_data_mock.upnp_factory.async_create_device.reset_mock()
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1525,7 +1526,7 @@ async def test_alive_but_gone(
# Send an SSDP notification with a new BOOTID, indicating the device has rebooted
domain_data_mock.upnp_factory.async_create_device.reset_mock()
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1546,7 +1547,7 @@ async def test_alive_but_gone(
# should result in a reconnect attempt even with same BOOTID.
domain_data_mock.upnp_factory.async_create_device.reset_mock()
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_st=MOCK_DEVICE_TYPE,
upnp={},
@@ -1554,7 +1555,7 @@ async def test_alive_but_gone(
ssdp.SsdpChange.BYEBYE,
)
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1597,7 +1598,7 @@ async def test_multiple_ssdp_alive(
# Send two SSDP notifications with the new device URL
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1606,7 +1607,7 @@ async def test_multiple_ssdp_alive(
ssdp.SsdpChange.ALIVE,
)
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -1637,7 +1638,7 @@ async def test_ssdp_byebye(
# First byebye will cause a disconnect
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={"NTS": "ssdp:byebye"},
@@ -1656,7 +1657,7 @@ async def test_ssdp_byebye(
# Second byebye will do nothing
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={"NTS": "ssdp:byebye"},
@@ -1689,7 +1690,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP alive with boot ID
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -1702,7 +1703,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP update with next boot ID
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -1727,7 +1728,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP update with same next boot ID, again
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -1752,7 +1753,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP update with bad next boot ID
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -1777,7 +1778,7 @@ async def test_ssdp_update_seen_bootid(
# Send a new SSDP alive with the new boot ID, device should not reconnect
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"},
@@ -1816,7 +1817,7 @@ async def test_ssdp_update_missed_bootid(
# Send SSDP alive with boot ID
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -1829,7 +1830,7 @@ async def test_ssdp_update_missed_bootid(
# Send SSDP update with skipped boot ID (not previously seen)
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -1854,7 +1855,7 @@ async def test_ssdp_update_missed_bootid(
# Send a new SSDP alive with the new boot ID, device should reconnect
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"},
@@ -1893,7 +1894,7 @@ async def test_ssdp_bootid(
# Send SSDP alive with boot ID
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -1913,7 +1914,7 @@ async def test_ssdp_bootid(
# Send SSDP alive with same boot ID, nothing should happen
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -1933,7 +1934,7 @@ async def test_ssdp_bootid(
# Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"},
@@ -2354,7 +2355,7 @@ async def test_connections_restored(
# Send an SSDP notification from the now alive device
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py
index 14da36a0381..76890f328e4 100644
--- a/tests/components/dlna_dms/test_config_flow.py
+++ b/tests/components/dlna_dms/test_config_flow.py
@@ -12,11 +12,17 @@ from async_upnp_client.exceptions import UpnpError
import pytest
from homeassistant import config_entries
-from homeassistant.components import ssdp
from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERVICE_LIST,
+ ATTR_UPNP_UDN,
+ SsdpServiceInfo,
+)
from .conftest import (
MOCK_DEVICE_HOST,
@@ -35,16 +41,16 @@ WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE"
-MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo(
+MOCK_DISCOVERY: Final = SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_st=MOCK_DEVICE_TYPE,
upnp={
- ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
- ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
- ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
- ssdp.ATTR_UPNP_SERVICE_LIST: {
+ ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
+ ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
+ ATTR_UPNP_SERVICE_LIST: {
"service": [
{
"SCPDURL": "/ContentDirectory/scpd.xml",
@@ -195,15 +201,15 @@ async def test_ssdp_flow_existing(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_st="mock_st",
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_udn=MOCK_DEVICE_UDN,
upnp={
- ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
- ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
- ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
+ ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN,
+ ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
},
),
)
@@ -279,7 +285,7 @@ async def test_duplicate_name(
ssdp_udn=new_device_udn,
)
discovery.upnp = dict(discovery.upnp)
- discovery.upnp[ssdp.ATTR_UPNP_UDN] = new_device_udn
+ discovery.upnp[ATTR_UPNP_UDN] = new_device_udn
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -312,15 +318,15 @@ async def test_ssdp_flow_upnp_udn(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
- data=ssdp.SsdpServiceInfo(
+ data=SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_st=MOCK_DEVICE_TYPE,
upnp={
- ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE",
- ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
- ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
+ ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE",
+ ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE,
+ ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME,
},
),
)
@@ -334,7 +340,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# No service list at all
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]
+ del discovery.upnp[ATTR_UPNP_SERVICE_LIST]
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
@@ -346,7 +352,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# Service list does not contain services
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"}
+ discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
@@ -358,10 +364,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# ContentDirectory service is missing
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {
+ discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {
"service": [
service
- for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"]
+ for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"]
if service.get("serviceId") != "urn:upnp-org:serviceId:ContentDirectory"
]
}
@@ -380,10 +386,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None:
"""
discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = dict(discovery.upnp)
- service_list = dict(discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST])
+ service_list = dict(discovery.upnp[ATTR_UPNP_SERVICE_LIST])
# Turn mock's list of service dicts into a single dict
service_list["service"] = service_list["service"][0]
- discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list
+ discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py
index 1be68f91733..01976c16247 100644
--- a/tests/components/dlna_dms/test_device_availability.py
+++ b/tests/components/dlna_dms/test_device_availability.py
@@ -18,6 +18,7 @@ from homeassistant.components.dlna_dms.dms import get_domain_data
from homeassistant.components.media_player import BrowseError
from homeassistant.components.media_source import Unresolvable
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .conftest import (
MOCK_DEVICE_LOCATION,
@@ -179,7 +180,7 @@ async def test_become_available(
# Send an SSDP notification from the now alive device
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -207,7 +208,7 @@ async def test_alive_but_gone(
# Send an SSDP notification from the still missing device
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -227,7 +228,7 @@ async def test_alive_but_gone(
# Send the same SSDP notification, expecting no extra connection attempts
upnp_factory_mock.async_create_device.reset_mock()
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -244,7 +245,7 @@ async def test_alive_but_gone(
# Send an SSDP notification with a new BOOTID, indicating the device has rebooted
upnp_factory_mock.async_create_device.reset_mock()
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -263,7 +264,7 @@ async def test_alive_but_gone(
# should result in a reconnect attempt even with same BOOTID.
upnp_factory_mock.async_create_device.reset_mock()
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_st=MOCK_DEVICE_TYPE,
upnp={},
@@ -271,7 +272,7 @@ async def test_alive_but_gone(
ssdp.SsdpChange.BYEBYE,
)
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -310,7 +311,7 @@ async def test_multiple_ssdp_alive(
# Send two SSDP notifications with the new device URL
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -319,7 +320,7 @@ async def test_multiple_ssdp_alive(
ssdp.SsdpChange.ALIVE,
)
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=NEW_DEVICE_LOCATION,
ssdp_st=MOCK_DEVICE_TYPE,
@@ -345,7 +346,7 @@ async def test_ssdp_byebye(
# First byebye will cause a disconnect
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={"NTS": "ssdp:byebye"},
@@ -360,7 +361,7 @@ async def test_ssdp_byebye(
# Second byebye will do nothing
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={"NTS": "ssdp:byebye"},
@@ -388,7 +389,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP alive with boot ID
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -405,7 +406,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP update with next boot ID
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -426,7 +427,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP update with same next boot ID, again
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -447,7 +448,7 @@ async def test_ssdp_update_seen_bootid(
# Send SSDP update with bad next boot ID
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -468,7 +469,7 @@ async def test_ssdp_update_seen_bootid(
# Send a new SSDP alive with the new boot ID, device should not reconnect
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"},
@@ -500,7 +501,7 @@ async def test_ssdp_update_missed_bootid(
# Send SSDP alive with boot ID
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -517,7 +518,7 @@ async def test_ssdp_update_missed_bootid(
# Send SSDP update with skipped boot ID (not previously seen)
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={
@@ -538,7 +539,7 @@ async def test_ssdp_update_missed_bootid(
# Send a new SSDP alive with the new boot ID, device should reconnect
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"},
@@ -570,7 +571,7 @@ async def test_ssdp_bootid(
# Send SSDP alive with boot ID
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -586,7 +587,7 @@ async def test_ssdp_bootid(
# Send SSDP alive with same boot ID, nothing should happen
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"},
@@ -602,7 +603,7 @@ async def test_ssdp_bootid(
# Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_location=MOCK_DEVICE_LOCATION,
ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"},
diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py
index 7907d40c415..5576066f781 100644
--- a/tests/components/dlna_dms/test_dms_device_source.py
+++ b/tests/components/dlna_dms/test_dms_device_source.py
@@ -16,6 +16,7 @@ from homeassistant.components.dlna_dms.dms import DidlPlayMedia
from homeassistant.components.media_player import BrowseError
from homeassistant.components.media_source import BrowseMediaSource, Unresolvable
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .conftest import (
MOCK_DEVICE_BASE_URL,
@@ -68,7 +69,7 @@ async def test_catch_request_error_unavailable(
# DmsDevice notifies of disconnect via SSDP
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target
await ssdp_callback(
- ssdp.SsdpServiceInfo(
+ SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={"NTS": "ssdp:byebye"},
diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py
index 3abdd2b87a3..98b2189dfd9 100644
--- a/tests/components/doorbird/test_config_flow.py
+++ b/tests/components/doorbird/test_config_flow.py
@@ -8,7 +8,6 @@ from doorbirdpy import DoorBird
import pytest
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.doorbird.const import (
CONF_EVENTS,
DEFAULT_DOORBELL_EVENT,
@@ -18,6 +17,7 @@ from homeassistant.components.doorbird.const import (
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import (
VALID_CONFIG,
@@ -74,7 +74,7 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.8"),
ip_addresses=[ip_address("192.168.1.8")],
hostname="mock_hostname",
@@ -94,7 +94,7 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("169.254.103.61"),
ip_addresses=[ip_address("169.254.103.61")],
hostname="mock_hostname",
@@ -121,7 +121,7 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("4.4.4.4"),
ip_addresses=[ip_address("4.4.4.4")],
hostname="mock_hostname",
@@ -142,7 +142,7 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"),
ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")],
hostname="mock_hostname",
@@ -164,7 +164,7 @@ async def test_form_zeroconf_correct_oui(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.5"),
ip_addresses=[ip_address("192.168.1.5")],
hostname="mock_hostname",
@@ -230,7 +230,7 @@ async def test_form_zeroconf_correct_oui_wrong_device(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.5"),
ip_addresses=[ip_address("192.168.1.5")],
hostname="mock_hostname",
diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py
index 6b008c7fac1..fda1ecc6cf6 100644
--- a/tests/components/dremel_3d_printer/test_init.py
+++ b/tests/components/dremel_3d_printer/test_init.py
@@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py
index 2301b9dfc80..ccb7920e141 100644
--- a/tests/components/dsmr/conftest.py
+++ b/tests/components/dsmr/conftest.py
@@ -45,9 +45,9 @@ def dsmr_connection_fixture() -> Generator[tuple[MagicMock, MagicMock, MagicMock
@pytest.fixture
-def rfxtrx_dsmr_connection_fixture() -> (
- Generator[tuple[MagicMock, MagicMock, MagicMock]]
-):
+def rfxtrx_dsmr_connection_fixture() -> Generator[
+ tuple[MagicMock, MagicMock, MagicMock]
+]:
"""Fixture that mocks RFXtrx connection."""
transport = MagicMock(spec=asyncio.Transport)
@@ -73,9 +73,9 @@ def rfxtrx_dsmr_connection_fixture() -> (
@pytest.fixture
-def dsmr_connection_send_validate_fixture() -> (
- Generator[tuple[MagicMock, MagicMock, MagicMock]]
-):
+def dsmr_connection_send_validate_fixture() -> Generator[
+ tuple[MagicMock, MagicMock, MagicMock]
+]:
"""Fixture that mocks serial connection."""
transport = MagicMock(spec=asyncio.Transport)
@@ -156,9 +156,9 @@ def dsmr_connection_send_validate_fixture() -> (
@pytest.fixture
-def rfxtrx_dsmr_connection_send_validate_fixture() -> (
- Generator[tuple[MagicMock, MagicMock, MagicMock]]
-):
+def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[
+ tuple[MagicMock, MagicMock, MagicMock]
+]:
"""Fixture that mocks serial connection."""
transport = MagicMock(spec=asyncio.Transport)
diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py
index 8fc996f6e34..9bcde251f6f 100644
--- a/tests/components/dsmr/test_diagnostics.py
+++ b/tests/components/dsmr/test_diagnostics.py
@@ -58,7 +58,7 @@ async def test_diagnostics(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m³"},
+ {"value": Decimal("745.695"), "unit": "m³"},
],
),
"GAS_METER_READING",
diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py
index 7c7d182aa97..d590666b060 100644
--- a/tests/components/dsmr/test_mbus_migration.py
+++ b/tests/components/dsmr/test_mbus_migration.py
@@ -10,6 +10,7 @@ from dsmr_parser.obis_references import (
MBUS_METER_READING,
)
from dsmr_parser.objects import CosemObject, MBusObject, Telegram
+import pytest
from homeassistant.components.dsmr.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
@@ -85,7 +86,7 @@ async def test_migrate_gas_to_mbus(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal("745.695"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -102,6 +103,17 @@ async def test_migrate_gas_to_mbus(
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
+ # Check a new device is created and the old device has been removed
+ assert len(device_registry.devices) == 1
+ assert not device_registry.async_get(device.id)
+ new_entity = entity_registry.async_get("sensor.gas_meter_reading")
+ new_device = device_registry.async_get(new_entity.device_id)
+ new_dev_entities = er.async_entries_for_device(
+ entity_registry, new_device.id, include_disabled_entities=True
+ )
+ assert new_dev_entities == [new_entity]
+
+ # Check no entities are connected to the old device
dev_entities = er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
)
@@ -185,7 +197,7 @@ async def test_migrate_hourly_gas_to_mbus(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1722749707)},
- {"value": Decimal(778.963), "unit": "m3"},
+ {"value": Decimal("778.963"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -202,6 +214,17 @@ async def test_migrate_hourly_gas_to_mbus(
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
+ # Check a new device is created and the old device has been removed
+ assert len(device_registry.devices) == 1
+ assert not device_registry.async_get(device.id)
+ new_entity = entity_registry.async_get("sensor.gas_meter_reading")
+ new_device = device_registry.async_get(new_entity.device_id)
+ new_dev_entities = er.async_entries_for_device(
+ entity_registry, new_device.id, include_disabled_entities=True
+ )
+ assert new_dev_entities == [new_entity]
+
+ # Check no entities are connected to the old device
dev_entities = er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
)
@@ -285,7 +308,7 @@ async def test_migrate_gas_with_devid_to_mbus(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal("745.695"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -302,6 +325,18 @@ async def test_migrate_gas_with_devid_to_mbus(
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
+ # Check a new device is not created and the old device has not been removed
+ assert len(device_registry.devices) == 1
+ assert device_registry.async_get(device.id)
+ new_entity = entity_registry.async_get("sensor.gas_meter_reading")
+ new_device = device_registry.async_get(new_entity.device_id)
+ assert new_device.id == device.id
+ # Check entities are still connected to the old device
+ dev_entities = er.async_entries_for_device(
+ entity_registry, device.id, include_disabled_entities=True
+ )
+ assert dev_entities == [new_entity]
+
assert (
entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id)
is None
@@ -319,6 +354,7 @@ async def test_migrate_gas_to_mbus_exists(
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
+ caplog: pytest.LogCaptureFixture,
) -> None:
"""Test migration of unique_id."""
(connection_factory, transport, protocol) = dsmr_connection_fixture
@@ -380,7 +416,7 @@ async def test_migrate_gas_to_mbus_exists(
telegram = Telegram()
telegram.add(
MBUS_DEVICE_TYPE,
- CosemObject((0, 0), [{"value": "003", "unit": ""}]),
+ CosemObject((0, 1), [{"value": "003", "unit": ""}]),
"MBUS_DEVICE_TYPE",
)
telegram.add(
@@ -397,7 +433,7 @@ async def test_migrate_gas_to_mbus_exists(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal("745.695"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -414,7 +450,32 @@ async def test_migrate_gas_to_mbus_exists(
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
+ # Check a new device is not created and the old device has not been removed
+ assert len(device_registry.devices) == 2
+ assert device_registry.async_get(device.id)
+ assert device_registry.async_get(device2.id)
+ entity = entity_registry.async_get("sensor.gas_meter_reading")
+ dev_entities = er.async_entries_for_device(
+ entity_registry, device.id, include_disabled_entities=True
+ )
+ assert dev_entities == [entity]
+ entity2 = entity_registry.async_get("sensor.gas_meter_reading_alt")
+ dev2_entities = er.async_entries_for_device(
+ entity_registry, device2.id, include_disabled_entities=True
+ )
+ assert dev2_entities == [entity2]
+
assert (
entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id)
== "sensor.gas_meter_reading"
)
+ assert (
+ entity_registry.async_get_entity_id(
+ SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331"
+ )
+ == "sensor.gas_meter_reading_alt"
+ )
+ assert (
+ "Skip migration of sensor.gas_meter_reading because it already exists"
+ in caplog.text
+ )
diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py
index 4a2951f4ed8..fbe14b38aa3 100644
--- a/tests/components/dsmr/test_sensor.py
+++ b/tests/components/dsmr/test_sensor.py
@@ -89,7 +89,7 @@ async def test_default_setup(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS},
+ {"value": Decimal("745.695"), "unit": UnitOfVolume.CUBIC_METERS},
],
),
"GAS_METER_READING",
@@ -152,7 +152,7 @@ async def test_default_setup(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642214)},
- {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS},
+ {"value": Decimal("745.701"), "unit": UnitOfVolume.CUBIC_METERS},
],
),
"GAS_METER_READING",
@@ -279,7 +279,7 @@ async def test_v4_meter(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal("745.695"), "unit": "m3"},
],
),
"HOURLY_GAS_METER_READING",
@@ -336,9 +336,9 @@ async def test_v4_meter(
@pytest.mark.parametrize(
("value", "state"),
[
- (Decimal(745.690), "745.69"),
- (Decimal(745.695), "745.695"),
- (Decimal(0.000), STATE_UNKNOWN),
+ (Decimal("745.690"), "745.69"),
+ (Decimal("745.695"), "745.695"),
+ (Decimal("0.000"), STATE_UNKNOWN),
],
)
async def test_v5_meter(
@@ -440,7 +440,7 @@ async def test_luxembourg_meter(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal("745.695"), "unit": "m3"},
],
),
"HOURLY_GAS_METER_READING",
@@ -449,7 +449,7 @@ async def test_luxembourg_meter(
ELECTRICITY_IMPORTED_TOTAL,
CosemObject(
(0, 0),
- [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
+ [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
),
"ELECTRICITY_IMPORTED_TOTAL",
)
@@ -457,7 +457,7 @@ async def test_luxembourg_meter(
ELECTRICITY_EXPORTED_TOTAL,
CosemObject(
(0, 0),
- [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
+ [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
),
"ELECTRICITY_EXPORTED_TOTAL",
)
@@ -533,7 +533,7 @@ async def test_belgian_meter(
BELGIUM_CURRENT_AVERAGE_DEMAND,
CosemObject(
(0, 0),
- [{"value": Decimal(1.75), "unit": "kW"}],
+ [{"value": Decimal("1.75"), "unit": "kW"}],
),
"BELGIUM_CURRENT_AVERAGE_DEMAND",
)
@@ -543,7 +543,7 @@ async def test_belgian_meter(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642218)},
- {"value": Decimal(4.11), "unit": "kW"},
+ {"value": Decimal("4.11"), "unit": "kW"},
],
),
"BELGIUM_MAXIMUM_DEMAND_MONTH",
@@ -567,7 +567,7 @@ async def test_belgian_meter(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal("745.695"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -591,7 +591,7 @@ async def test_belgian_meter(
(0, 2),
[
{"value": datetime.datetime.fromtimestamp(1551642214)},
- {"value": Decimal(678.695), "unit": "m3"},
+ {"value": Decimal("678.695"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -615,7 +615,7 @@ async def test_belgian_meter(
(0, 3),
[
{"value": datetime.datetime.fromtimestamp(1551642215)},
- {"value": Decimal(12.12), "unit": "m3"},
+ {"value": Decimal("12.12"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -639,7 +639,7 @@ async def test_belgian_meter(
(0, 4),
[
{"value": datetime.datetime.fromtimestamp(1551642216)},
- {"value": Decimal(13.13), "unit": "m3"},
+ {"value": Decimal("13.13"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -782,7 +782,7 @@ async def test_belgian_meter_alt(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1551642215)},
- {"value": Decimal(123.456), "unit": "m3"},
+ {"value": Decimal("123.456"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -806,7 +806,7 @@ async def test_belgian_meter_alt(
(0, 2),
[
{"value": datetime.datetime.fromtimestamp(1551642216)},
- {"value": Decimal(678.901), "unit": "m3"},
+ {"value": Decimal("678.901"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -830,7 +830,7 @@ async def test_belgian_meter_alt(
(0, 3),
[
{"value": datetime.datetime.fromtimestamp(1551642217)},
- {"value": Decimal(12.12), "unit": "m3"},
+ {"value": Decimal("12.12"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -854,7 +854,7 @@ async def test_belgian_meter_alt(
(0, 4),
[
{"value": datetime.datetime.fromtimestamp(1551642218)},
- {"value": Decimal(13.13), "unit": "m3"},
+ {"value": Decimal("13.13"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -1001,7 +1001,7 @@ async def test_belgian_meter_mbus(
(0, 3),
[
{"value": datetime.datetime.fromtimestamp(1551642217)},
- {"value": Decimal(12.12), "unit": "m3"},
+ {"value": Decimal("12.12"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -1017,7 +1017,7 @@ async def test_belgian_meter_mbus(
(0, 4),
[
{"value": datetime.datetime.fromtimestamp(1551642218)},
- {"value": Decimal(13.13), "unit": "m3"},
+ {"value": Decimal("13.13"), "unit": "m3"},
],
),
"MBUS_METER_READING",
@@ -1154,7 +1154,7 @@ async def test_swedish_meter(
ELECTRICITY_IMPORTED_TOTAL,
CosemObject(
(0, 0),
- [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
+ [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
),
"ELECTRICITY_IMPORTED_TOTAL",
)
@@ -1162,7 +1162,7 @@ async def test_swedish_meter(
ELECTRICITY_EXPORTED_TOTAL,
CosemObject(
(0, 0),
- [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
+ [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
),
"ELECTRICITY_EXPORTED_TOTAL",
)
@@ -1229,7 +1229,7 @@ async def test_easymeter(
ELECTRICITY_IMPORTED_TOTAL,
CosemObject(
(0, 0),
- [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
+ [{"value": Decimal("54184.6316"), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
),
"ELECTRICITY_IMPORTED_TOTAL",
)
@@ -1237,7 +1237,7 @@ async def test_easymeter(
ELECTRICITY_EXPORTED_TOTAL,
CosemObject(
(0, 0),
- [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
+ [{"value": Decimal("19981.1069"), "unit": UnitOfEnergy.KILO_WATT_HOUR}],
),
"ELECTRICITY_EXPORTED_TOTAL",
)
@@ -1489,7 +1489,7 @@ async def test_gas_meter_providing_energy_reading(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE},
+ {"value": Decimal("123.456"), "unit": UnitOfEnergy.GIGA_JOULE},
],
),
"GAS_METER_READING",
@@ -1549,7 +1549,7 @@ async def test_heat_meter_mbus(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "GJ"},
+ {"value": Decimal("745.695"), "unit": "GJ"},
],
),
"MBUS_METER_READING",
diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py
index 2ddd8395e78..86805fb456f 100644
--- a/tests/components/dsmr_reader/test_definitions.py
+++ b/tests/components/dsmr_reader/test_definitions.py
@@ -12,7 +12,7 @@ from homeassistant.components.dsmr_reader.sensor import DSMRSensor
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
-from tests.common import MockConfigEntry, async_fire_mqtt_message
+from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message
@pytest.mark.parametrize(
@@ -93,6 +93,7 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None:
)
sensor = DSMRSensor(description, config_entry)
sensor.hass = hass
+ sensor.platform = MockEntityPlatform(hass)
await sensor.async_added_to_hass()
# Test dsmr version, if it's a digit
diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py
index ed4182f450f..f74ef43bf07 100644
--- a/tests/components/duke_energy/conftest.py
+++ b/tests/components/duke_energy/conftest.py
@@ -11,12 +11,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
-from tests.typing import RecorderInstanceGenerator
+from tests.typing import RecorderInstanceContextManager
@pytest.fixture
async def mock_recorder_before_hass(
- async_test_recorder: RecorderInstanceGenerator,
+ async_test_recorder: RecorderInstanceContextManager,
) -> None:
"""Set up recorder."""
diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py
index 4bf4eb53ad6..3335e12b2a2 100644
--- a/tests/components/dynalite/test_init.py
+++ b/tests/components/dynalite/test_init.py
@@ -5,7 +5,7 @@ from unittest.mock import call, patch
import pytest
from voluptuous import MultipleInvalid
-import homeassistant.components.dynalite.const as dynalite
+from homeassistant.components.dynalite import const as dynalite
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
diff --git a/tests/components/eafm/conftest.py b/tests/components/eafm/conftest.py
index 3b060563a30..5dbdc98ad29 100644
--- a/tests/components/eafm/conftest.py
+++ b/tests/components/eafm/conftest.py
@@ -15,5 +15,5 @@ def mock_get_stations():
@pytest.fixture
def mock_get_station():
"""Mock aioeafm.get_station."""
- with patch("homeassistant.components.eafm.get_station") as patched:
+ with patch("homeassistant.components.eafm.coordinator.get_station") as patched:
yield patched
diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py
index add604167b9..11febb26669 100644
--- a/tests/components/eafm/test_sensor.py
+++ b/tests/components/eafm/test_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py
index 5c919ffab5c..9edb1d42331 100644
--- a/tests/components/ecobee/test_config_flow.py
+++ b/tests/components/ecobee/test_config_flow.py
@@ -2,15 +2,8 @@
from unittest.mock import patch
-from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN
-import pytest
-
from homeassistant.components.ecobee import config_flow
-from homeassistant.components.ecobee.const import (
- CONF_REFRESH_TOKEN,
- DATA_ECOBEE_CONFIG,
- DOMAIN,
-)
+from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
@@ -35,7 +28,6 @@ async def test_user_step_without_user_input(hass: HomeAssistant) -> None:
"""Test expected result if user step is called."""
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {}
result = await flow.async_step_user()
assert result["type"] is FlowResultType.FORM
@@ -46,7 +38,6 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None:
"""Test expected result if pin request succeeds."""
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {}
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
@@ -64,7 +55,6 @@ async def test_pin_request_fails(hass: HomeAssistant) -> None:
"""Test expected result if pin request fails."""
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {}
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
@@ -81,7 +71,6 @@ async def test_token_request_succeeds(hass: HomeAssistant) -> None:
"""Test expected result if token request succeeds."""
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {}
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
@@ -105,7 +94,6 @@ async def test_token_request_fails(hass: HomeAssistant) -> None:
"""Test expected result if token request fails."""
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {}
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
@@ -120,99 +108,3 @@ async def test_token_request_fails(hass: HomeAssistant) -> None:
assert result["step_id"] == "authorize"
assert result["errors"]["base"] == "token_request_failed"
assert result["description_placeholders"] == {"pin": "test-pin"}
-
-
-@pytest.mark.skip(reason="Flaky/slow")
-async def test_import_flow_triggered_but_no_ecobee_conf(hass: HomeAssistant) -> None:
- """Test expected result if import flow triggers but ecobee.conf doesn't exist."""
- flow = config_flow.EcobeeFlowHandler()
- flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {}
-
- result = await flow.async_step_import(import_data=None)
-
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
-
-
-async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_tokens(
- hass: HomeAssistant,
-) -> None:
- """Test expected result if import flow triggers and ecobee.conf exists with valid tokens."""
- flow = config_flow.EcobeeFlowHandler()
- flow.hass = hass
-
- MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None}
-
- with (
- patch(
- "homeassistant.components.ecobee.config_flow.load_json_object",
- return_value=MOCK_ECOBEE_CONF,
- ),
- patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee,
- ):
- mock_ecobee = mock_ecobee.return_value
- mock_ecobee.refresh_tokens.return_value = True
- mock_ecobee.api_key = "test-api-key"
- mock_ecobee.refresh_token = "test-token"
-
- result = await flow.async_step_import(import_data=None)
-
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == DOMAIN
- assert result["data"] == {
- CONF_API_KEY: "test-api-key",
- CONF_REFRESH_TOKEN: "test-token",
- }
-
-
-async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data(
- hass: HomeAssistant,
-) -> None:
- """Test expected result if import flow triggers and ecobee.conf exists with invalid data."""
- flow = config_flow.EcobeeFlowHandler()
- flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"}
-
- MOCK_ECOBEE_CONF = {}
-
- with (
- patch(
- "homeassistant.components.ecobee.config_flow.load_json_object",
- return_value=MOCK_ECOBEE_CONF,
- ),
- patch.object(flow, "async_step_user") as mock_async_step_user,
- ):
- await flow.async_step_import(import_data=None)
-
- mock_async_step_user.assert_called_once_with(
- user_input={CONF_API_KEY: "test-api-key"}
- )
-
-
-async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_tokens(
- hass: HomeAssistant,
-) -> None:
- """Test expected result if import flow triggers and ecobee.conf exists with stale tokens."""
- flow = config_flow.EcobeeFlowHandler()
- flow.hass = hass
- flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"}
-
- MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None}
-
- with (
- patch(
- "homeassistant.components.ecobee.config_flow.load_json_object",
- return_value=MOCK_ECOBEE_CONF,
- ),
- patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee,
- patch.object(flow, "async_step_user") as mock_async_step_user,
- ):
- mock_ecobee = mock_ecobee.return_value
- mock_ecobee.refresh_tokens.return_value = False
-
- await flow.async_step_import(import_data=None)
-
- mock_async_step_user.assert_called_once_with(
- user_input={CONF_API_KEY: "test-api-key"}
- )
diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py
index 85bfff08bdf..8678cfd4d05 100644
--- a/tests/components/ecoforest/conftest.py
+++ b/tests/components/ecoforest/conftest.py
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch
from pyecoforest.models.device import Alarm, Device, OperationMode, State
import pytest
-from homeassistant.components.ecoforest import DOMAIN
+from homeassistant.components.ecoforest.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py
index 2ef10c1bd41..2fc4356d1d8 100644
--- a/tests/components/econet/test_config_flow.py
+++ b/tests/components/econet/test_config_flow.py
@@ -5,7 +5,7 @@ from unittest.mock import patch
from pyeconet.api import EcoNetApiInterface
from pyeconet.errors import InvalidCredentialsError, PyeconetError
-from homeassistant.components.econet import DOMAIN
+from homeassistant.components.econet.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py
index 65e0b19ea02..3021db62e6f 100644
--- a/tests/components/ecovacs/test_button.py
+++ b/tests/components/ecovacs/test_button.py
@@ -161,8 +161,8 @@ async def test_disabled_by_default_buttons(
for entity_id in entity_ids:
assert not hass.states.get(entity_id)
- assert (
- entry := entity_registry.async_get(entity_id)
- ), f"Entity registry entry for {entity_id} is missing"
+ assert (entry := entity_registry.async_get(entity_id)), (
+ f"Entity registry entry for {entity_id} is missing"
+ )
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py
index 2185ae4c9eb..13b73d853d5 100644
--- a/tests/components/ecovacs/test_init.py
+++ b/tests/components/ecovacs/test_init.py
@@ -116,6 +116,6 @@ async def test_all_entities_loaded(
entities: int,
) -> None:
"""Test that all entities are loaded together."""
- assert (
- hass.states.async_entity_ids_count() == entities
- ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}"
+ assert hass.states.async_entity_ids_count() == entities, (
+ f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}"
+ )
diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py
index a735863d40a..32bc8f90696 100644
--- a/tests/components/ecovacs/test_number.py
+++ b/tests/components/ecovacs/test_number.py
@@ -136,9 +136,9 @@ async def test_disabled_by_default_number_entities(
for entity_id in entity_ids:
assert not hass.states.get(entity_id)
- assert (
- entry := entity_registry.async_get(entity_id)
- ), f"Entity registry entry for {entity_id} is missing"
+ assert (entry := entity_registry.async_get(entity_id)), (
+ f"Entity registry entry for {entity_id} is missing"
+ )
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py
index 5bcd8385320..8222e9976d5 100644
--- a/tests/components/ecovacs/test_sensor.py
+++ b/tests/components/ecovacs/test_sensor.py
@@ -172,9 +172,9 @@ async def test_disabled_by_default_sensors(
for entity_id in entity_ids:
assert not hass.states.get(entity_id)
- assert (
- entry := entity_registry.async_get(entity_id)
- ), f"Entity registry entry for {entity_id} is missing"
+ assert (entry := entity_registry.async_get(entity_id)), (
+ f"Entity registry entry for {entity_id} is missing"
+ )
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py
index b14cafeaba4..040528debaa 100644
--- a/tests/components/ecovacs/test_switch.py
+++ b/tests/components/ecovacs/test_switch.py
@@ -214,8 +214,8 @@ async def test_disabled_by_default_switch_entities(
for entity_id in entity_ids:
assert not hass.states.get(entity_id)
- assert (
- entry := entity_registry.async_get(entity_id)
- ), f"Entity registry entry for {entity_id} is missing"
+ assert (entry := entity_registry.async_get(entity_id)), (
+ f"Entity registry entry for {entity_id} is missing"
+ )
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py
index addaa1b9c48..49c18bab239 100644
--- a/tests/components/efergy/test_sensor.py
+++ b/tests/components/efergy/test_sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform
diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py
index cdad628de6b..ef52eade9ae 100644
--- a/tests/components/eheimdigital/conftest.py
+++ b/tests/components/eheimdigital/conftest.py
@@ -4,8 +4,9 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
+from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.hub import EheimDigitalHub
-from eheimdigital.types import EheimDeviceType, LightMode
+from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode
import pytest
from homeassistant.components.eheimdigital.const import DOMAIN
@@ -39,7 +40,26 @@ def classic_led_ctrl_mock():
@pytest.fixture
-def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]:
+def heater_mock():
+ """Mock a Heater device."""
+ heater_mock = MagicMock(spec=EheimDigitalHeater)
+ heater_mock.mac_address = "00:00:00:00:00:02"
+ heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ heater_mock.name = "Mock Heater"
+ heater_mock.aquarium_name = "Mock Aquarium"
+ heater_mock.temperature_unit = HeaterUnit.CELSIUS
+ heater_mock.current_temperature = 24.2
+ heater_mock.target_temperature = 25.5
+ heater_mock.is_heating = True
+ heater_mock.is_active = True
+ heater_mock.operation_mode = HeaterMode.MANUAL
+ return heater_mock
+
+
+@pytest.fixture
+def eheimdigital_hub_mock(
+ classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock
+) -> Generator[AsyncMock]:
"""Mock eheimdigital hub."""
with (
patch(
@@ -52,7 +72,8 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo
),
):
eheimdigital_hub_mock.return_value.devices = {
- "00:00:00:00:00:01": classic_led_ctrl_mock
+ "00:00:00:00:00:01": classic_led_ctrl_mock,
+ "00:00:00:00:00:02": heater_mock,
}
eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock
yield eheimdigital_hub_mock
diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr
new file mode 100644
index 00000000000..171d3d427fc
--- /dev/null
+++ b/tests/components/eheimdigital/snapshots/test_climate.ambr
@@ -0,0 +1,153 @@
+# serializer version: 1
+# name: test_dynamic_new_devices[climate.mock_heater-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'hvac_modes': list([
+ ,
+ ,
+ ]),
+ 'max_temp': 32,
+ 'min_temp': 18,
+ 'preset_modes': list([
+ 'none',
+ 'bio_mode',
+ 'smart_mode',
+ ]),
+ 'target_temp_step': 0.5,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'climate',
+ 'entity_category': None,
+ 'entity_id': 'climate.mock_heater',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'eheimdigital',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': 'heater',
+ 'unique_id': '00:00:00:00:00:02',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_dynamic_new_devices[climate.mock_heater-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'current_temperature': 24.2,
+ 'friendly_name': 'Mock Heater',
+ 'hvac_action': ,
+ 'hvac_modes': list([
+ ,
+ ,
+ ]),
+ 'max_temp': 32,
+ 'min_temp': 18,
+ 'preset_mode': 'none',
+ 'preset_modes': list([
+ 'none',
+ 'bio_mode',
+ 'smart_mode',
+ ]),
+ 'supported_features': ,
+ 'target_temp_step': 0.5,
+ 'temperature': 25.5,
+ }),
+ 'context': ,
+ 'entity_id': 'climate.mock_heater',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'auto',
+ })
+# ---
+# name: test_setup_heater[climate.mock_heater-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'hvac_modes': list([
+ ,
+ ,
+ ]),
+ 'max_temp': 32,
+ 'min_temp': 18,
+ 'preset_modes': list([
+ 'none',
+ 'bio_mode',
+ 'smart_mode',
+ ]),
+ 'target_temp_step': 0.5,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'climate',
+ 'entity_category': None,
+ 'entity_id': 'climate.mock_heater',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'eheimdigital',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': 'heater',
+ 'unique_id': '00:00:00:00:00:02',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_setup_heater[climate.mock_heater-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'current_temperature': 24.2,
+ 'friendly_name': 'Mock Heater',
+ 'hvac_action': ,
+ 'hvac_modes': list([
+ ,
+ ,
+ ]),
+ 'max_temp': 32,
+ 'min_temp': 18,
+ 'preset_mode': 'none',
+ 'preset_modes': list([
+ 'none',
+ 'bio_mode',
+ 'smart_mode',
+ ]),
+ 'supported_features': ,
+ 'target_temp_step': 0.5,
+ 'temperature': 25.5,
+ }),
+ 'context': ,
+ 'entity_id': 'climate.mock_heater',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'auto',
+ })
+# ---
diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py
new file mode 100644
index 00000000000..f1f29ce9d34
--- /dev/null
+++ b/tests/components/eheimdigital/test_climate.py
@@ -0,0 +1,254 @@
+"""Tests for the climate module."""
+
+from unittest.mock import MagicMock, patch
+
+from eheimdigital.types import (
+ EheimDeviceType,
+ EheimDigitalClientError,
+ HeaterMode,
+ HeaterUnit,
+)
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.climate import (
+ ATTR_HVAC_MODE,
+ ATTR_PRESET_MODE,
+ DOMAIN as CLIMATE_DOMAIN,
+ PRESET_NONE,
+ SERVICE_SET_HVAC_MODE,
+ SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_TEMPERATURE,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.components.eheimdigital.const import (
+ HEATER_BIO_MODE,
+ HEATER_SMART_MODE,
+)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.mark.usefixtures("heater_mock")
+async def test_setup_heater(
+ hass: HomeAssistant,
+ eheimdigital_hub_mock: MagicMock,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test climate platform setup for heater."""
+ mock_config_entry.add_to_hass(hass)
+
+ with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
+ "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ )
+ await hass.async_block_till_done()
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_dynamic_new_devices(
+ hass: HomeAssistant,
+ eheimdigital_hub_mock: MagicMock,
+ heater_mock: MagicMock,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test light platform setup with at first no devices and dynamically adding a device."""
+ mock_config_entry.add_to_hass(hass)
+
+ eheimdigital_hub_mock.return_value.devices = {}
+
+ with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ assert (
+ len(
+ entity_registry.entities.get_entries_for_config_entry_id(
+ mock_config_entry.entry_id
+ )
+ )
+ == 0
+ )
+
+ eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock}
+
+ await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
+ "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ )
+ await hass.async_block_till_done()
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+@pytest.mark.parametrize(
+ ("preset_mode", "heater_mode"),
+ [
+ (PRESET_NONE, HeaterMode.MANUAL),
+ (HEATER_BIO_MODE, HeaterMode.BIO),
+ (HEATER_SMART_MODE, HeaterMode.SMART),
+ ],
+)
+async def test_set_preset_mode(
+ hass: HomeAssistant,
+ eheimdigital_hub_mock: MagicMock,
+ heater_mock: MagicMock,
+ mock_config_entry: MockConfigEntry,
+ preset_mode: str,
+ heater_mode: HeaterMode,
+) -> None:
+ """Test setting a preset mode."""
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
+ "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ )
+ await hass.async_block_till_done()
+
+ heater_mock.set_operation_mode.side_effect = EheimDigitalClientError
+
+ with pytest.raises(HomeAssistantError):
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_PRESET_MODE,
+ {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode},
+ blocking=True,
+ )
+
+ heater_mock.set_operation_mode.side_effect = None
+
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_PRESET_MODE,
+ {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode},
+ blocking=True,
+ )
+
+ heater_mock.set_operation_mode.assert_awaited_with(heater_mode)
+
+
+async def test_set_temperature(
+ hass: HomeAssistant,
+ eheimdigital_hub_mock: MagicMock,
+ heater_mock: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting a preset mode."""
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
+ "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ )
+ await hass.async_block_till_done()
+
+ heater_mock.set_target_temperature.side_effect = EheimDigitalClientError
+
+ with pytest.raises(HomeAssistantError):
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_TEMPERATURE,
+ {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0},
+ blocking=True,
+ )
+
+ heater_mock.set_target_temperature.side_effect = None
+
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_TEMPERATURE,
+ {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0},
+ blocking=True,
+ )
+
+ heater_mock.set_target_temperature.assert_awaited_with(26.0)
+
+
+@pytest.mark.parametrize(
+ ("hvac_mode", "active"), [(HVACMode.AUTO, True), (HVACMode.OFF, False)]
+)
+async def test_set_hvac_mode(
+ hass: HomeAssistant,
+ eheimdigital_hub_mock: MagicMock,
+ heater_mock: MagicMock,
+ mock_config_entry: MockConfigEntry,
+ hvac_mode: HVACMode,
+ active: bool,
+) -> None:
+ """Test setting a preset mode."""
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
+ "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ )
+ await hass.async_block_till_done()
+
+ heater_mock.set_active.side_effect = EheimDigitalClientError
+
+ with pytest.raises(HomeAssistantError):
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode},
+ blocking=True,
+ )
+
+ heater_mock.set_active.side_effect = None
+
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode},
+ blocking=True,
+ )
+
+ heater_mock.set_active.assert_awaited_with(active=active)
+
+
+async def test_state_update(
+ hass: HomeAssistant,
+ eheimdigital_hub_mock: MagicMock,
+ mock_config_entry: MockConfigEntry,
+ heater_mock: MagicMock,
+) -> None:
+ """Test the climate state update."""
+ heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT
+ heater_mock.is_heating = False
+ heater_mock.operation_mode = HeaterMode.BIO
+
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
+ "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
+ )
+ await hass.async_block_till_done()
+
+ assert (state := hass.states.get("climate.mock_heater"))
+
+ assert state.attributes["hvac_action"] == HVACAction.IDLE
+ assert state.attributes["preset_mode"] == HEATER_BIO_MODE
+
+ heater_mock.is_active = False
+ heater_mock.operation_mode = HeaterMode.SMART
+
+ await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
+
+ assert (state := hass.states.get("climate.mock_heater"))
+ assert state.state == HVACMode.OFF
+ assert state.attributes["preset_mode"] == HEATER_SMART_MODE
diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py
index e75cf31eb98..4bfd45e9259 100644
--- a/tests/components/eheimdigital/test_config_flow.py
+++ b/tests/components/eheimdigital/test_config_flow.py
@@ -7,11 +7,11 @@ from aiohttp import ClientConnectionError
import pytest
from homeassistant.components.eheimdigital.const import DOMAIN
-from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("192.0.2.1"),
diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py
index bb3304ec66c..a85eb16a986 100644
--- a/tests/components/electric_kiwi/test_sensor.py
+++ b/tests/components/electric_kiwi/test_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .conftest import ComponentSetup, YieldFixture
diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py
index 7151aab10f2..c4234cb38ae 100644
--- a/tests/components/elevenlabs/test_tts.py
+++ b/tests/components/elevenlabs/test_tts.py
@@ -13,6 +13,7 @@ import pytest
from homeassistant.components import tts
from homeassistant.components.elevenlabs.const import (
+ ATTR_MODEL,
CONF_MODEL,
CONF_OPTIMIZE_LATENCY,
CONF_SIMILARITY,
@@ -163,6 +164,16 @@ async def mock_config_entry_setup(
@pytest.mark.parametrize(
("setup", "tts_service", "service_data"),
[
+ (
+ "mock_config_entry_setup",
+ "speak",
+ {
+ ATTR_ENTITY_ID: "tts.mock_title",
+ tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
+ tts.ATTR_MESSAGE: "There is a person at the front door.",
+ tts.ATTR_OPTIONS: {},
+ },
+ ),
(
"mock_config_entry_setup",
"speak",
@@ -173,6 +184,26 @@ async def mock_config_entry_setup(
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"},
},
),
+ (
+ "mock_config_entry_setup",
+ "speak",
+ {
+ ATTR_ENTITY_ID: "tts.mock_title",
+ tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
+ tts.ATTR_MESSAGE: "There is a person at the front door.",
+ tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"},
+ },
+ ),
+ (
+ "mock_config_entry_setup",
+ "speak",
+ {
+ ATTR_ENTITY_ID: "tts.mock_title",
+ tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
+ tts.ATTR_MESSAGE: "There is a person at the front door.",
+ tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"},
+ },
+ ),
],
indirect=["setup"],
)
@@ -206,11 +237,13 @@ async def test_tts_service_speak(
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
== HTTPStatus.OK
)
+ voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1")
+ model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1")
tts_entity._client.generate.assert_called_once_with(
text="There is a person at the front door.",
- voice="voice2",
- model="model1",
+ voice=voice_id,
+ model=model_id,
voice_settings=tts_entity._voice_settings,
optimize_streaming_latency=tts_entity._latency,
)
diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py
index 00763f60458..c647d36902a 100644
--- a/tests/components/elgato/test_config_flow.py
+++ b/tests/components/elgato/test_config_flow.py
@@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock
from elgato import ElgatoConnectionError
import pytest
-from homeassistant.components import zeroconf
from homeassistant.components.elgato.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
@@ -57,7 +57,7 @@ async def test_full_zeroconf_flow_implementation(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
@@ -141,7 +141,7 @@ async def test_zeroconf_connection_error(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -181,7 +181,7 @@ async def test_zeroconf_device_exists_abort(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="mock_hostname",
@@ -202,7 +202,7 @@ async def test_zeroconf_device_exists_abort(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.2"),
ip_addresses=[ip_address("127.0.0.2")],
hostname="mock_hostname",
@@ -230,7 +230,7 @@ async def test_zeroconf_during_onboarding(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data=zeroconf.ZeroconfServiceInfo(
+ data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py
index e56bb5f4699..5355013bf94 100644
--- a/tests/components/elkm1/test_config_flow.py
+++ b/tests/components/elkm1/test_config_flow.py
@@ -7,12 +7,12 @@ from elkm1_lib.discovery import ElkSystem
import pytest
from homeassistant import config_entries
-from homeassistant.components import dhcp
from homeassistant.components.elkm1.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import (
ELK_DISCOVERY,
@@ -27,7 +27,7 @@ from . import (
from tests.common import MockConfigEntry
-DHCP_DISCOVERY = dhcp.DhcpServiceInfo(
+DHCP_DISCOVERY = DhcpServiceInfo(
MOCK_IP_ADDRESS, "", dr.format_mac(MOCK_MAC).replace(":", "")
)
ELK_DISCOVERY_INFO = asdict(ELK_DISCOVERY)
@@ -1141,7 +1141,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None:
result3 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
- data=dhcp.DhcpServiceInfo(
+ data=DhcpServiceInfo(
hostname="any",
ip=MOCK_IP_ADDRESS,
macaddress="00:00:00:00:00:00",
diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py
index 76dc8845662..88fc0a33c51 100644
--- a/tests/components/elmax/test_alarm_control_panel.py
+++ b/tests/components/elmax/test_alarm_control_panel.py
@@ -5,7 +5,7 @@ from unittest.mock import patch
from syrupy import SnapshotAssertion
-from homeassistant.components.elmax import POLLING_SECONDS
+from homeassistant.components.elmax.const import POLLING_SECONDS
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py
index 7a4d9755fa5..be89ee4d5d6 100644
--- a/tests/components/elmax/test_config_flow.py
+++ b/tests/components/elmax/test_config_flow.py
@@ -5,7 +5,6 @@ from unittest.mock import patch
from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError
from homeassistant import config_entries
-from homeassistant.components import zeroconf
from homeassistant.components.elmax.const import (
CONF_ELMAX_MODE,
CONF_ELMAX_MODE_CLOUD,
@@ -23,6 +22,7 @@ from homeassistant.components.elmax.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import (
MOCK_DIRECT_CERT,
@@ -40,7 +40,7 @@ from . import (
from tests.common import MockConfigEntry
-MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
+MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo(
ip_address=MOCK_DIRECT_HOST,
ip_addresses=[MOCK_DIRECT_HOST],
hostname="VideoBox.local",
@@ -54,7 +54,7 @@ MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
},
type="_elmax-ssl._tcp",
)
-MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo(
+MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo(
ip_address=MOCK_DIRECT_HOST_CHANGED,
ip_addresses=[MOCK_DIRECT_HOST_CHANGED],
hostname="VideoBox.local",
@@ -68,7 +68,7 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo(
},
type="_elmax-ssl._tcp",
)
-MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = zeroconf.ZeroconfServiceInfo(
+MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo(
ip_address=MOCK_DIRECT_HOST,
ip_addresses=[MOCK_DIRECT_HOST],
hostname="VideoBox.local",
diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py
index e77ebcc08b0..3e5f4004d1a 100644
--- a/tests/components/emonitor/test_config_flow.py
+++ b/tests/components/emonitor/test_config_flow.py
@@ -6,15 +6,15 @@ from aioemonitor.monitor import EmonitorNetwork, EmonitorStatus
import aiohttp
from homeassistant import config_entries
-from homeassistant.components import dhcp
from homeassistant.components.emonitor.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from tests.common import MockConfigEntry
-DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo(
+DHCP_SERVICE_INFO = DhcpServiceInfo(
hostname="emonitor",
ip="1.2.3.4",
macaddress="aabbccddeeff",
diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py
index 8a340d5e2dd..97dcc782096 100644
--- a/tests/components/emulated_hue/test_hue_api.py
+++ b/tests/components/emulated_hue/test_hue_api.py
@@ -58,7 +58,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType
from tests.common import (
diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py
index c142e436fd3..d0301034cf8 100644
--- a/tests/components/energenie_power_sockets/conftest.py
+++ b/tests/components/energenie_power_sockets/conftest.py
@@ -44,7 +44,7 @@ def get_pyegps_device_mock() -> MagicMock:
fkObj = FakePowerStrip(
devId=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], number_of_sockets=4
)
- fkObj.release = lambda: True
+ fkObj.release = lambda: None
fkObj._status = [0, 1, 0, 1]
usb_device_mock = MagicMock(wraps=fkObj)
diff --git a/tests/components/energenie_power_sockets/test_init.py b/tests/components/energenie_power_sockets/test_init.py
index 4e2fe51665b..a11cef319b2 100644
--- a/tests/components/energenie_power_sockets/test_init.py
+++ b/tests/components/energenie_power_sockets/test_init.py
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock
from pyegps.exceptions import UsbError
-from homeassistant.components.energenie_power_sockets.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -24,13 +23,11 @@ async def test_load_unload_entry(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
- assert entry.entry_id in hass.data[DOMAIN]
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
- assert DOMAIN not in hass.data
async def test_device_not_found_on_load_entry(
diff --git a/tests/components/energenie_power_sockets/test_switch.py b/tests/components/energenie_power_sockets/test_switch.py
index 4cd2bd60028..27f13390a83 100644
--- a/tests/components/energenie_power_sockets/test_switch.py
+++ b/tests/components/energenie_power_sockets/test_switch.py
@@ -6,7 +6,6 @@ from pyegps.exceptions import EgpsException
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.energenie_power_sockets.const import DOMAIN
from homeassistant.components.homeassistant import (
DOMAIN as HOME_ASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
@@ -118,7 +117,6 @@ async def test_switch_setup(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
- assert entry.entry_id in hass.data[DOMAIN]
state = hass.states.get(f"switch.{entity_name}")
assert state == snapshot
diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py
index a27451b853d..a438842f8a5 100644
--- a/tests/components/energy/test_sensor.py
+++ b/tests/components/energy/test_sensor.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from tests.components.recorder.common import async_wait_recording_done
diff --git a/tests/components/energyzero/__init__.py b/tests/components/energyzero/__init__.py
index 287bdf6a2f4..35a1346790f 100644
--- a/tests/components/energyzero/__init__.py
+++ b/tests/components/energyzero/__init__.py
@@ -1 +1,12 @@
"""Tests for the EnergyZero integration."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
+ """Fixture for setting up the integration."""
+ config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py
index d42283c0d4b..3fd93ee31f8 100644
--- a/tests/components/energyzero/conftest.py
+++ b/tests/components/energyzero/conftest.py
@@ -29,7 +29,8 @@ def mock_config_entry() -> MockConfigEntry:
title="energy",
domain=DOMAIN,
data={},
- unique_id="unique_thingy",
+ unique_id=DOMAIN,
+ entry_id="12345",
)
diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr
deleted file mode 100644
index 72e504c97c8..00000000000
--- a/tests/components/energyzero/snapshots/test_config_flow.ambr
+++ /dev/null
@@ -1,39 +0,0 @@
-# serializer version: 1
-# name: test_full_user_flow
- FlowResultSnapshot({
- 'context': dict({
- 'source': 'user',
- 'unique_id': 'energyzero',
- }),
- 'data': dict({
- }),
- 'description': None,
- 'description_placeholders': None,
- 'flow_id': ,
- 'handler': 'energyzero',
- 'minor_version': 1,
- 'options': dict({
- }),
- 'result': ConfigEntrySnapshot({
- 'data': dict({
- }),
- 'disabled_by': None,
- 'discovery_keys': dict({
- }),
- 'domain': 'energyzero',
- 'entry_id': ,
- 'minor_version': 1,
- 'options': dict({
- }),
- 'pref_disable_new_entities': False,
- 'pref_disable_polling': False,
- 'source': 'user',
- 'title': 'EnergyZero',
- 'unique_id': 'energyzero',
- 'version': 1,
- }),
- 'title': 'EnergyZero',
- 'type': ,
- 'version': 1,
- })
-# ---
diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr
index 90c11ecfc6f..aaa52cfeb7e 100644
--- a/tests/components/energyzero/snapshots/test_diagnostics.ambr
+++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr
@@ -1,26 +1,4 @@
# serializer version: 1
-# name: test_diagnostics
- dict({
- 'energy': dict({
- 'average_price': 0.37,
- 'current_hour_price': 0.49,
- 'highest_price_time': '2022-12-07T16:00:00+00:00',
- 'hours_priced_equal_or_lower': 23,
- 'lowest_price_time': '2022-12-07T02:00:00+00:00',
- 'max_price': 0.55,
- 'min_price': 0.26,
- 'next_hour_price': 0.55,
- 'percentage_of_max': 89.09,
- }),
- 'entry': dict({
- 'title': 'energy',
- }),
- 'gas': dict({
- 'current_hour_price': 1.47,
- 'next_hour_price': 1.47,
- }),
- })
-# ---
# name: test_diagnostics_no_gas_today
dict({
'energy': dict({
@@ -43,3 +21,25 @@
}),
})
# ---
+# name: test_entry_diagnostics
+ dict({
+ 'energy': dict({
+ 'average_price': 0.37,
+ 'current_hour_price': 0.49,
+ 'highest_price_time': '2022-12-07T16:00:00+00:00',
+ 'hours_priced_equal_or_lower': 23,
+ 'lowest_price_time': '2022-12-07T02:00:00+00:00',
+ 'max_price': 0.55,
+ 'min_price': 0.26,
+ 'next_hour_price': 0.55,
+ 'percentage_of_max': 89.09,
+ }),
+ 'entry': dict({
+ 'title': 'energy',
+ }),
+ 'gas': dict({
+ 'current_hour_price': 1.47,
+ 'next_hour_price': 1.47,
+ }),
+ })
+# ---
diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr
index 3a66f25fd32..452f4ae748e 100644
--- a/tests/components/energyzero/snapshots/test_sensor.ambr
+++ b/tests/components/energyzero/snapshots/test_sensor.ambr
@@ -1,20 +1,5 @@
# serializer version: 1
-# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'attribution': 'Data provided by EnergyZero',
- 'friendly_name': 'Energy market price Average - today',
- 'unit_of_measurement': '€/kWh',
- }),
- 'context': ,
- 'entity_id': 'sensor.energyzero_today_energy_average_price',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '0.37',
- })
-# ---
-# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1
+# name: test_sensor[sensor.energyzero_today_energy_average_price-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -43,52 +28,26 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'average_price',
+ 'unique_id': '12345_today_energy_average_price',
'unit_of_measurement': '€/kWh',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2
- DeviceRegistryEntrySnapshot({
- 'area_id': None,
- 'config_entries': ,
- 'configuration_url': None,
- 'connections': set({
- }),
- 'disabled_by': None,
- 'entry_type': ,
- 'hw_version': None,
- 'id': ,
- 'is_new': False,
- 'labels': set({
- }),
- 'manufacturer': 'EnergyZero',
- 'model': None,
- 'model_id': None,
- 'name': 'Energy market price',
- 'name_by_user': None,
- 'primary_config_entry': ,
- 'serial_number': None,
- 'suggested_area': None,
- 'sw_version': None,
- 'via_device_id': None,
- })
-# ---
-# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy]
+# name: test_sensor[sensor.energyzero_today_energy_average_price-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by EnergyZero',
- 'friendly_name': 'Energy market price Current hour',
- 'state_class': ,
+ 'friendly_name': 'Energy market price Average - today',
'unit_of_measurement': '€/kWh',
}),
'context': ,
- 'entity_id': 'sensor.energyzero_today_energy_current_hour_price',
+ 'entity_id': 'sensor.energyzero_today_energy_average_price',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '0.49',
+ 'state': '0.37',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1
+# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -119,51 +78,27 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'current_hour_price',
+ 'unique_id': '12345_today_energy_current_hour_price',
'unit_of_measurement': '€/kWh',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2
- DeviceRegistryEntrySnapshot({
- 'area_id': None,
- 'config_entries': ,
- 'configuration_url': None,
- 'connections': set({
- }),
- 'disabled_by': None,
- 'entry_type': ,
- 'hw_version': None,
- 'id': ,
- 'is_new': False,
- 'labels': set({
- }),
- 'manufacturer': 'EnergyZero',
- 'model': None,
- 'model_id': None,
- 'name': 'Energy market price',
- 'name_by_user': None,
- 'primary_config_entry': ,
- 'serial_number': None,
- 'suggested_area': None,
- 'sw_version': None,
- 'via_device_id': None,
- })
-# ---
-# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy]
+# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by EnergyZero',
- 'device_class': 'timestamp',
- 'friendly_name': 'Energy market price Time of highest price - today',
+ 'friendly_name': 'Energy market price Current hour',
+ 'state_class': ,
+ 'unit_of_measurement': '€/kWh',
}),
'context': ,
- 'entity_id': 'sensor.energyzero_today_energy_highest_price_time',
+ 'entity_id': 'sensor.energyzero_today_energy_current_hour_price',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '2022-12-07T16:00:00+00:00',
+ 'state': '0.49',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1
+# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -192,51 +127,26 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'highest_price_time',
+ 'unique_id': '12345_today_energy_highest_price_time',
'unit_of_measurement': None,
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2
- DeviceRegistryEntrySnapshot({
- 'area_id': None,
- 'config_entries': ,
- 'configuration_url': None,
- 'connections': set({
- }),
- 'disabled_by': None,
- 'entry_type': ,
- 'hw_version': None,
- 'id': ,
- 'is_new': False,
- 'labels': set({
- }),
- 'manufacturer': 'EnergyZero',
- 'model': None,
- 'model_id': None,
- 'name': 'Energy market price',
- 'name_by_user': None,
- 'primary_config_entry': ,
- 'serial_number': None,
- 'suggested_area': None,
- 'sw_version': None,
- 'via_device_id': None,
- })
-# ---
-# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy]
+# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by EnergyZero',
- 'friendly_name': 'Energy market price Hours priced equal or lower than current - today',
- 'unit_of_measurement': ,
+ 'device_class': 'timestamp',
+ 'friendly_name': 'Energy market price Time of highest price - today',
}),
'context': ,
- 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower',
+ 'entity_id': 'sensor.energyzero_today_energy_highest_price_time',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '23',
+ 'state': '2022-12-07T16:00:00+00:00',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1
+# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -265,51 +175,74 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'hours_priced_equal_or_lower',
+ 'unique_id': '12345_today_energy_hours_priced_equal_or_lower',
'unit_of_measurement': ,
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2
- DeviceRegistryEntrySnapshot({
- 'area_id': None,
- 'config_entries': ,
- 'configuration_url': None,
- 'connections': set({
- }),
- 'disabled_by': None,
- 'entry_type': ,
- 'hw_version': None,
- 'id': ,
- 'is_new': False,
- 'labels': set({
- }),
- 'manufacturer': 'EnergyZero',
- 'model': None,
- 'model_id': None,
- 'name': 'Energy market price',
- 'name_by_user': None,
- 'primary_config_entry': ,
- 'serial_number': None,
- 'suggested_area': None,
- 'sw_version': None,
- 'via_device_id': None,
- })
-# ---
-# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy]
+# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by EnergyZero',
- 'friendly_name': 'Energy market price Highest price - today',
- 'unit_of_measurement': '€/kWh',
+ 'friendly_name': 'Energy market price Hours priced equal or lower than current - today',
+ 'unit_of_measurement': ,
}),
'context': ,
- 'entity_id': 'sensor.energyzero_today_energy_max_price',
+ 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '0.55',
+ 'state': '23',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1
+# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-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.energyzero_today_energy_lowest_price_time',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Time of lowest price - today',
+ 'platform': 'energyzero',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'lowest_price_time',
+ 'unique_id': '12345_today_energy_lowest_price_time',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by EnergyZero',
+ 'device_class': 'timestamp',
+ 'friendly_name': 'Energy market price Time of lowest price - today',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2022-12-07T02:00:00+00:00',
+ })
+# ---
+# name: test_sensor[sensor.energyzero_today_energy_max_price-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -338,52 +271,170 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'max_price',
+ 'unique_id': '12345_today_energy_max_price',
'unit_of_measurement': '€/kWh',
})
# ---
-# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2
- DeviceRegistryEntrySnapshot({
- 'area_id': None,
- 'config_entries': ,
- 'configuration_url': None,
- 'connections': set({
- }),
- 'disabled_by': None,
- 'entry_type': ,
- 'hw_version': None,
- 'id': ,
- 'is_new': False,
- 'labels': set({
- }),
- 'manufacturer': 'EnergyZero',
- 'model': None,
- 'model_id': None,
- 'name': 'Energy market price',
- 'name_by_user': None,
- 'primary_config_entry': ,
- 'serial_number': None,
- 'suggested_area': None,
- 'sw_version': None,
- 'via_device_id': None,
- })
-# ---
-# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas]
+# name: test_sensor[sensor.energyzero_today_energy_max_price-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by EnergyZero',
- 'friendly_name': 'Gas market price Current hour',
- 'state_class': ,
- 'unit_of_measurement': '€/m³',
+ 'friendly_name': 'Energy market price Highest price - today',
+ 'unit_of_measurement': '€/kWh',
}),
'context': ,
- 'entity_id': 'sensor.energyzero_today_gas_current_hour_price',
+ 'entity_id': 'sensor.energyzero_today_energy_max_price',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '1.47',
+ 'state': '0.55',
})
# ---
-# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].1
+# name: test_sensor[sensor.energyzero_today_energy_min_price-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.energyzero_today_energy_min_price',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Lowest price - today',
+ 'platform': 'energyzero',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'min_price',
+ 'unique_id': '12345_today_energy_min_price',
+ 'unit_of_measurement': '€/kWh',
+ })
+# ---
+# name: test_sensor[sensor.energyzero_today_energy_min_price-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by EnergyZero',
+ 'friendly_name': 'Energy market price Lowest price - today',
+ 'unit_of_measurement': '€/kWh',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.energyzero_today_energy_min_price',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.26',
+ })
+# ---
+# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-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.energyzero_today_energy_next_hour_price',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Next hour',
+ 'platform': 'energyzero',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'next_hour_price',
+ 'unique_id': '12345_today_energy_next_hour_price',
+ 'unit_of_measurement': '€/kWh',
+ })
+# ---
+# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by EnergyZero',
+ 'friendly_name': 'Energy market price Next hour',
+ 'unit_of_measurement': '€/kWh',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.energyzero_today_energy_next_hour_price',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated':